Dashboard adoptation

This commit is contained in:
Sidharth Prabhu
2026-04-20 21:08:17 +05:30
parent 10d250686b
commit ee98c1c45e
10 changed files with 418 additions and 15 deletions

View File

@@ -25,6 +25,7 @@ import Reports from './pages/Reports.tsx';
import Feedback from './pages/Feedback.tsx';
import Ritz from './pages/Ritz.tsx';
import RitzCirculation from './pages/RitzCirculation.tsx';
import ManageWallets from './pages/ManageWallets.tsx';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const isLoggedIn = sessionStorage.getItem('isLoggedIn') === 'true';
@@ -94,6 +95,7 @@ function App() {
<Route path="feedback" element={<Feedback />} />
<Route path="ritz/overview" element={<Ritz />} />
<Route path="ritz/circulation" element={<RitzCirculation />} />
<Route path="ritz/wallets" element={<ManageWallets />} />
{/* Stores */}
<Route path="stores/terminals" element={<Terminals />} />

View File

@@ -122,7 +122,8 @@ const menuItems: MenuItem[] = [
icon: CircleDollarSign,
subMenu: [
{ title: 'Overview', path: '/ritz/overview' },
{ title: 'Ritz in Circulation', path: '/ritz/circulation' }
{ title: 'Ritz in Circulation', path: '/ritz/circulation' },
{ title: 'Manage Wallets', path: '/ritz/wallets' }
]
},
];

View File

@@ -33,6 +33,22 @@ const Dashboard = () => {
const [data, setData] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const toLocalISOString = (date: Date) => {
const tzo = -date.getTimezoneOffset(),
dif = tzo >= 0 ? '+' : '-',
pad = (num: number) => {
const norm = Math.floor(Math.abs(num));
return (norm < 10 ? '0' : '') + norm;
};
return date.getFullYear() +
'-' + pad(date.getMonth() + 1) +
'-' + pad(date.getDate()) +
'T' + pad(date.getHours()) +
':' + pad(date.getMinutes()) +
':' + pad(date.getSeconds()) +
'.' + pad(date.getMilliseconds());
};
const getRangeDates = (range: string) => {
const now = new Date();
const start = new Date();
@@ -65,13 +81,13 @@ const Dashboard = () => {
from.setHours(0, 0, 0, 0);
const to = new Date(customDates.to);
to.setHours(23, 59, 59, 999);
return { from: from.toISOString(), to: to.toISOString() };
return { from: toLocalISOString(from), to: toLocalISOString(to) };
}
return null;
default:
start.setHours(0, 0, 0, 0);
}
return { from: start.toISOString(), to: end.toISOString() };
return { from: toLocalISOString(start), to: toLocalISOString(end) };
};
useEffect(() => {
@@ -88,6 +104,7 @@ const Dashboard = () => {
url += `?${params.toString()}`;
}
console.log('[DASHBOARD-TRACE] Fetching stats from:', url);
const response = await fetch(url);
if (response.ok) {
const result = await response.json();

View File

@@ -0,0 +1,294 @@
import { useState, useEffect } from 'react';
import {
Users,
Search,
Wallet,
Plus,
X,
CheckCircle2,
AlertCircle,
ArrowRight,
User as UserIcon,
Phone
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
interface User {
id: number;
name: string;
mobileNumber: string;
ritzTokenBalance: number;
}
const ManageWallets = () => {
const [users, setUsers] = useState<User[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [amount, setAmount] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const [status, setStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const fetchUsers = async () => {
try {
setIsLoading(true);
const response = await fetch('/api/wallet/users');
if (response.ok) {
const data = await response.json();
setUsers(data);
}
} catch (error) {
console.error('Error fetching users:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
const handleTopUp = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedUser || !amount || parseFloat(amount) <= 0) return;
setIsProcessing(true);
setStatus(null);
try {
const response = await fetch('/api/wallet/topup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: selectedUser.id,
amount: parseFloat(amount)
}),
});
const data = await response.json();
if (data.success) {
setStatus({ type: 'success', message: `Successfully added ${amount} Ritz to ${selectedUser.name}'s wallet` });
fetchUsers(); // Refresh list
setAmount('');
setTimeout(() => {
setIsModalOpen(false);
setStatus(null);
}, 2000);
} else {
setStatus({ type: 'error', message: data.error || 'Failed to credit tokens' });
}
} catch (error) {
setStatus({ type: 'error', message: 'Connection error. Please try again.' });
} finally {
setIsProcessing(false);
}
};
const filteredUsers = users.filter(user =>
(user.name?.toLowerCase() || '').includes(searchQuery.toLowerCase()) ||
user.mobileNumber.includes(searchQuery)
);
return (
<div className="p-8 space-y-8 bg-slate-50/50 min-h-screen font-inter">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex items-center gap-2 mb-1">
<h2 className="text-xs font-black text-[#0f4475] uppercase tracking-widest">Ritz Section</h2>
</div>
<h1 className="text-2xl font-black text-slate-800">Manage Wallets</h1>
<p className="text-[10px] text-slate-400 font-medium flex items-center gap-1.5 mt-1">
<span className="p-0.5 bg-slate-200 rounded text-slate-500">i</span>
Admin panel for manual Ritz Token crediting and balance monitoring.
</p>
</div>
<div className="flex items-center gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
<input
type="text"
placeholder="Search user or mobile..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-white border border-slate-200 rounded-xl pl-10 pr-4 py-2 text-sm outline-none focus:border-[#0f4475] transition-all w-64 shadow-sm"
/>
</div>
</div>
</div>
{/* Main Content */}
<div className="bg-white rounded-[32px] border border-slate-100 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-50/50 border-b border-slate-100">
<th className="px-8 py-5 text-left text-[10px] font-black text-slate-400 uppercase tracking-widest">User ID</th>
<th className="px-8 py-5 text-left text-[10px] font-black text-slate-400 uppercase tracking-widest">Customer Details</th>
<th className="px-8 py-5 text-left text-[10px] font-black text-slate-400 uppercase tracking-widest">Mobile Number</th>
<th className="px-8 py-5 text-right text-[10px] font-black text-slate-400 uppercase tracking-widest">Wallet Balance</th>
<th className="px-8 py-5 text-center text-[10px] font-black text-slate-400 uppercase tracking-widest">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{isLoading ? (
<tr>
<td colSpan={5} className="py-20 text-center">
<motion.div
animate={{ scale: [1, 1.05, 1], opacity: [0.5, 1, 0.5] }}
transition={{ repeat: Infinity, duration: 1.5 }}
className="text-[10px] font-black text-[#0f4475] uppercase tracking-widest"
>
Loading Wallet Data...
</motion.div>
</td>
</tr>
) : filteredUsers.length === 0 ? (
<tr>
<td colSpan={5} className="py-20 text-center text-slate-400 text-sm font-medium">
No users found matching your search.
</td>
</tr>
) : (
filteredUsers.map((user) => (
<tr key={user.id} className="hover:bg-slate-50/50 transition-colors group">
<td className="px-8 py-5 text-sm font-black text-slate-400 tabular-nums">#{user.id}</td>
<td className="px-8 py-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-[#0f4475]/5 flex items-center justify-center text-[#0f4475]">
<UserIcon size={18} />
</div>
<div>
<p className="text-sm font-black text-slate-800 leading-none mb-1">{user.name || 'Anonymous User'}</p>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Registered Member</p>
</div>
</div>
</td>
<td className="px-8 py-5">
<div className="flex items-center gap-2 text-slate-600 font-bold text-sm">
<Phone size={14} className="text-slate-300" />
{user.mobileNumber}
</div>
</td>
<td className="px-8 py-5 text-right">
<div className="inline-flex items-center gap-2 bg-emerald-50 px-4 py-2 rounded-xl">
<Wallet size={14} className="text-emerald-500" />
<span className="text-lg font-black text-emerald-600 tracking-tighter">R{user.ritzTokenBalance.toLocaleString()}</span>
</div>
</td>
<td className="px-8 py-5 text-center">
<button
onClick={() => {
setSelectedUser(user);
setIsModalOpen(true);
}}
className="bg-[#0f4475] text-white px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest hover:scale-105 transition-all shadow-lg shadow-[#0f4475]/10 active:scale-95"
>
Credit Wallet
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Credit Modal */}
<AnimatePresence>
{isModalOpen && selectedUser && (
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-auto">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => !isProcessing && setIsModalOpen(false)}
className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
/>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
className="relative w-[450px] bg-white rounded-[32px] shadow-2xl overflow-hidden border border-slate-100"
>
<div className="p-8">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<div className="p-2 bg-[#0f4475]/10 text-[#0f4475] rounded-xl">
<Plus size={20} />
</div>
<h3 className="text-lg font-black text-slate-800">Credit Ritz Tokens</h3>
</div>
<button
onClick={() => setIsModalOpen(false)}
className="p-2 hover:bg-slate-50 rounded-xl transition-colors"
>
<X size={20} className="text-slate-400" />
</button>
</div>
<div className="bg-slate-50 p-4 rounded-2xl mb-8 border border-slate-100">
<div className="flex items-center gap-3 mb-1">
<UserIcon size={14} className="text-slate-400" />
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Target User</span>
</div>
<p className="text-sm font-black text-slate-800">{selectedUser.name}</p>
<p className="text-xs font-bold text-slate-500">{selectedUser.mobileNumber}</p>
</div>
<form onSubmit={handleTopUp} className="space-y-6">
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-2 block">Token Amount (Ritz)</label>
<div className="relative">
<div className="absolute left-4 top-1/2 -translate-y-1/2 font-black text-[#0f4475] text-lg">R</div>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
className="w-full bg-slate-50 border border-slate-100 rounded-2xl pl-10 pr-4 py-4 text-2xl font-black text-slate-800 outline-none focus:bg-white focus:border-[#0f4475]/30 transition-all"
required
autoFocus
/>
</div>
</div>
{status && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className={`p-4 rounded-2xl flex items-center gap-3 ${status.type === 'success' ? 'bg-emerald-50 text-emerald-600' : 'bg-rose-50 text-rose-600'}`}
>
{status.type === 'success' ? <CheckCircle2 size={18} /> : <AlertCircle size={18} />}
<span className="text-xs font-black uppercase tracking-tight leading-tight">{status.message}</span>
</motion.div>
)}
<button
type="submit"
disabled={isProcessing || !amount}
className="w-full bg-[#0f4475] text-white py-4 rounded-2xl font-black uppercase tracking-widest shadow-xl shadow-[#0f4475]/20 hover:scale-[1.02] transition-all flex items-center justify-center gap-2 group disabled:opacity-50 disabled:hover:scale-100"
>
{isProcessing ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
Confirm Credit
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
</>
)}
</button>
</form>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
};
export default ManageWallets;

View File

@@ -41,16 +41,67 @@ const StoreDashboard = () => {
return isNaN(num) ? '0' : num.toLocaleString();
};
const toLocalISOString = (date: Date) => {
const tzo = -date.getTimezoneOffset(),
pad = (num: number) => {
const norm = Math.floor(Math.abs(num));
return (norm < 10 ? '0' : '') + norm;
};
return date.getFullYear() +
'-' + pad(date.getMonth() + 1) +
'-' + pad(date.getDate()) +
'T' + pad(date.getHours()) +
':' + pad(date.getMinutes()) +
':' + pad(date.getSeconds()) +
'.' + pad(date.getMilliseconds());
};
const getRangeDates = (range: string) => {
const now = new Date();
const start = new Date();
const end = new Date();
// Set end to end of today
end.setHours(23, 59, 59, 999);
switch (range) {
case 'Today':
start.setHours(0, 0, 0, 0);
break;
case 'Yesterday':
start.setDate(now.getDate() - 1);
start.setHours(0, 0, 0, 0);
end.setDate(now.getDate() - 1);
end.setHours(23, 59, 59, 999);
break;
case 'Week':
start.setDate(now.getDate() - 7);
start.setHours(0, 0, 0, 0);
break;
case '30 Days':
start.setDate(now.getDate() - 30);
start.setHours(0, 0, 0, 0);
break;
default:
start.setHours(0, 0, 0, 0);
}
return { from: toLocalISOString(start), to: toLocalISOString(end) };
};
useEffect(() => {
const fetchStats = async () => {
try {
setIsLoading(true);
const response = await fetch('/api/dashboard/stats');
const range = getRangeDates(timeRange);
const params = new URLSearchParams();
params.append('from', range.from);
params.append('to', range.to);
const response = await fetch(`/api/dashboard/stats?${params.toString()}`);
if (response.ok) {
const data = await response.json();
console.log('Dashboard data received successfully:', data);
// Set basic stats from general stats if available
if (data.stats) {
setStats({
totalSales: data.stats.totalSales,
@@ -60,7 +111,6 @@ const StoreDashboard = () => {
});
}
// Override with RIT Canteen specific data if found in overview
if (data.storeOverview && data.storeOverview.length > 0) {
const ritStore = data.storeOverview.find((s: any) => s.name === 'RIT Canteen');
if (ritStore) {
@@ -68,12 +118,11 @@ const StoreDashboard = () => {
...prev,
totalSales: ritStore.sale,
activeOrders: ritStore.orders,
dailyCustomers: ritStore.orders * 0.9, // Approximation
dailyCustomers: ritStore.orders * 0.9,
}));
}
}
// Set other dynamic data
if (data.trendingItems) setTrendingItems(data.trendingItems);
if (data.hourlySales) setSalesData(data.hourlySales);
if (data.insights) setInsights(data.insights);
@@ -85,7 +134,7 @@ const StoreDashboard = () => {
}
};
fetchStats();
}, []);
}, [timeRange]);
const pieData = [
{ name: 'Full Payment', value: Number(stats.totalSales) || 0, color: '#8b5cf6' },