Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
363 changes: 363 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

193 changes: 166 additions & 27 deletions src/app/components/DashboardLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Outlet, useNavigate, useLocation } from 'react-router';
import { LayoutDashboard, Car, Users, Wallet, Settings, Bell, User, LogOut, Shield, BarChart3, UserCog, PhoneCall, Moon, Sun } from 'lucide-react';
import { LayoutDashboard, Car, Users, Wallet, Settings, Bell, User, LogOut, Shield, BarChart3, UserCog, PhoneCall, Moon, Sun, CheckCheck } from 'lucide-react';
import { Button } from '../components/ui/button';
import {
DropdownMenu,
Expand All @@ -17,7 +17,7 @@ import {
SelectTrigger,
SelectValue,
} from '../components/ui/select';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { useAppContext } from '../contexts/AppContext';
import { useAuth } from '../contexts/AuthContext';
Expand All @@ -43,37 +43,109 @@ const adminNavigation = [
{ nameKey: 'nav.settings', href: '/settings', icon: Settings },
];

interface NotificationItem {
id: string;
message: string;
time: Date;
read: boolean;
type: 'info' | 'success' | 'warning';
}

export default function DashboardLayout() {
const navigate = useNavigate();
const location = useLocation();
const { user, role: userRole, logout } = useAuth();
const userName = user?.name ?? 'User';
const { t, language, setLanguage, theme, setTheme } = useAppContext();
const [notifications, setNotifications] = useState(0);

// Live dispatch notifications over WebSockets (centralized for the whole dashboard).
const [notifItems, setNotifItems] = useState<NotificationItem[]>([
// Sample notification so admin can preview the UI
{
id: 'sample-1',
message: 'Welcome! Ride updates and status changes will appear here in real time.',
time: new Date(),
read: false,
type: 'info' as const,
},
]);
const [notifOpen, setNotifOpen] = useState(false);
const notifRef = useRef<HTMLDivElement>(null);

const unreadCount = notifItems.filter((n) => !n.read).length;

// Close on outside click or Esc
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setNotifOpen(false); };
const onClick = (e: MouseEvent) => {
if (notifRef.current && !notifRef.current.contains(e.target as Node)) setNotifOpen(false);
};
document.addEventListener('keydown', onKey);
document.addEventListener('mousedown', onClick);
return () => {
document.removeEventListener('keydown', onKey);
document.removeEventListener('mousedown', onClick);
};
}, []);

// Live dispatch notifications over WebSockets
useEffect(() => {
const socket = getSocket() ?? connectSocket();

const onStatus = (data: any) => {
setNotifications((n) => n + 1);
const label = data?.statusLabel ?? rideStatusLabel(data?.status);
toast(`Ride ${String(data?.rideId ?? '').slice(0, 8)} → ${label}`);
const msg = `Ride ${String(data?.rideId ?? '').slice(0, 8)} → ${label}`;
toast(msg);
setNotifItems((prev) => [{
id: `${Date.now()}-${Math.random()}`,
message: msg,
time: new Date(),
read: false,
type: 'info' as const,
}, ...prev].slice(0, 50));
};

const onCompleted = (data: any) => {
setNotifications((n) => n + 1);
toast.success(`Ride completed · ${data?.fare ?? ''} ${data?.currency ?? 'ETB'}`);
const msg = `Ride completed · ${data?.fare ?? ''} ${data?.currency ?? 'ETB'}`;
toast.success(msg);
setNotifItems((prev) => [{
id: `${Date.now()}-${Math.random()}`,
message: msg,
time: new Date(),
read: false,
type: 'success' as const,
}, ...prev].slice(0, 50));
};

socket.on('ride:status', onStatus);
socket.on('ride:completed', onCompleted);

return () => {
socket.off('ride:status', onStatus);
socket.off('ride:completed', onCompleted);
};
}, []);

const markRead = (id: string) => {
setNotifItems((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
};

const markAllRead = () => {
setNotifItems((prev) => prev.map((n) => ({ ...n, read: true })));
};

const formatTime = (d: Date) => {
const diff = Math.floor((Date.now() - d.getTime()) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return d.toLocaleDateString();
};

const typeColor = (type: NotificationItem['type']) => ({
info: 'bg-[#00BDC3]',
success: 'bg-[#10B981]',
warning: 'bg-[#F59E0B]',
})[type];

const navigation = userRole === 'admin' ? adminNavigation : operatorNavigation;

const handleLogout = () => {
Expand All @@ -85,7 +157,6 @@ export default function DashboardLayout() {
<div className="min-h-screen flex bg-background">
{/* Sidebar */}
<aside className="w-[260px] bg-sidebar border-r border-sidebar-border flex flex-col">
{/* Logo */}
<div className="h-16 flex items-center justify-center border-b border-sidebar-border">
<div className="flex items-center gap-2">
<div className="w-10 h-10 rounded-full bg-[#00BDC3] flex items-center justify-center">
Expand All @@ -95,7 +166,6 @@ export default function DashboardLayout() {
</div>
</div>

{/* Navigation */}
<nav className="flex-1 p-4 space-y-1">
{navigation.map((item) => {
const isActive = location.pathname === item.href;
Expand All @@ -116,7 +186,6 @@ export default function DashboardLayout() {
})}
</nav>

{/* User Profile at Bottom */}
<div className="p-4 border-t border-sidebar-border">
<div className="flex items-center gap-3 px-2">
<div className="w-10 h-10 rounded-full bg-sidebar-accent flex items-center justify-center">
Expand All @@ -128,7 +197,9 @@ export default function DashboardLayout() {
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm text-sidebar-foreground truncate">{userName}</p>
<p className="text-xs text-muted-foreground">{userRole === 'admin' ? 'Administrator' : 'Call Center'}</p>
<p className="text-xs text-muted-foreground">
{userRole === 'admin' ? t('auth.admin', 'Administrator') : t('common.callCenter', 'Call Center')}
</p>
</div>
</div>
</div>
Expand All @@ -145,7 +216,7 @@ export default function DashboardLayout() {
{userRole === 'admin' && (
<Badge className="bg-[#00BDC3] text-white hover:bg-[#00BDC3]">
<Shield className="w-3 h-3 mr-1" />
Admin
{t('common.adminBadge', 'Admin')}
</Badge>
)}
</div>
Expand Down Expand Up @@ -175,14 +246,82 @@ export default function DashboardLayout() {
)}
</Button>

<Button variant="ghost" size="icon" className="relative" onClick={() => setNotifications(0)}>
<Bell className="w-5 h-5 text-muted-foreground" />
{notifications > 0 && (
<Badge className="absolute -top-1 -right-1 w-5 h-5 flex items-center justify-center p-0 bg-[#EF4444] text-white text-xs">
{notifications > 9 ? '9+' : notifications}
</Badge>
{/* Notification Bell */}
<div className="relative" ref={notifRef}>
<Button
variant="ghost"
size="icon"
className="relative"
onClick={() => setNotifOpen((o) => !o)}
aria-label="Notifications"
>
<Bell className="w-5 h-5 text-muted-foreground" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 flex items-center justify-center rounded-full bg-[#EF4444] text-white text-[10px] font-bold leading-none">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</Button>

{/* Dropdown panel */}
{notifOpen && (
<div className="absolute right-0 top-full mt-2 w-80 z-50 rounded-xl border border-border bg-card shadow-xl overflow-hidden animate-in fade-in slide-in-from-top-2 duration-150">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2">
<span className="font-semibold text-foreground text-sm">Notifications</span>
{unreadCount > 0 && (
<span className="bg-[#EF4444] text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full">
{unreadCount}
</span>
)}
</div>
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="flex items-center gap-1 text-xs text-[#00BDC3] hover:text-[#009EA3] transition-colors"
>
<CheckCheck className="w-3.5 h-3.5" />
Mark all read
</button>
)}
</div>

{/* List */}
<div className="max-h-80 overflow-y-auto">
{notifItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 px-4 text-center">
<Bell className="w-8 h-8 text-muted-foreground mb-2 opacity-40" />
<p className="text-sm text-muted-foreground">No notifications yet</p>
<p className="text-xs text-muted-foreground mt-1 opacity-60">Ride updates will appear here</p>
</div>
) : (
notifItems.map((n) => (
<button
key={n.id}
onClick={() => markRead(n.id)}
className={`w-full text-left px-4 py-3 flex items-start gap-3 border-b border-border/50 last:border-0 transition-colors hover:bg-muted/60 ${
!n.read ? 'bg-muted/40' : ''
}`}
>
{/* Color dot */}
<span className={`mt-1.5 w-2 h-2 rounded-full flex-shrink-0 ${typeColor(n.type)} ${n.read ? 'opacity-30' : ''}`} />
<div className="flex-1 min-w-0">
<p className={`text-sm leading-snug ${!n.read ? 'font-semibold text-foreground' : 'text-muted-foreground'}`}>
{n.message}
</p>
<p className="text-xs text-muted-foreground mt-0.5">{formatTime(n.time)}</p>
</div>
{!n.read && (
<span className="mt-1.5 w-2 h-2 rounded-full bg-[#00BDC3] flex-shrink-0" />
)}
</button>
))
)}
</div>
</div>
)}
</Button>
</div>

<Button variant="outline" size="icon" onClick={handleLogout} className="border-border text-foreground hover:bg-accent">
<LogOut className="w-5 h-5" />
Expand All @@ -203,20 +342,20 @@ export default function DashboardLayout() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuLabel>{t('common.account', 'My Account')}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<User className="w-4 h-4 mr-2" />
Profile
{t('common.profile', 'Profile')}
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/settings')}>
<Settings className="w-4 h-4 mr-2" />
Settings
{t('nav.settings', 'Settings')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="w-4 h-4 mr-2" />
Logout
{t('common.logout', 'Logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
Expand All @@ -230,4 +369,4 @@ export default function DashboardLayout() {
</div>
</div>
);
}
}
26 changes: 14 additions & 12 deletions src/app/components/LogCallDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
import { PhoneCall, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { useAppContext } from '../contexts/AppContext';

const empty = { customerPhone: '', customerName: '', durationMinutes: '', status: 'completed', notes: '' };

/** Lets an operator record a call (esp. missed/abandoned ones that don't become rides). */
export default function LogCallDialog({ onLogged, className }: { onLogged?: () => void; className?: string }) {
const { t } = useAppContext();
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState(empty);
Expand Down Expand Up @@ -46,34 +48,34 @@ export default function LogCallDialog({ onLogged, className }: { onLogged?: () =
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className={`gap-2 ${className ?? ''}`}>
<PhoneCall className="w-4 h-4" /> Log Call
<PhoneCall className="w-4 h-4" /> {t('dashboard.logCall', 'Log Call')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader><DialogTitle>Log a Call</DialogTitle></DialogHeader>
<DialogHeader><DialogTitle>{t('dashboard.logCallTitle', 'Log a Call')}</DialogTitle></DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2"><Label>Customer Phone *</Label><Input value={form.customerPhone} onChange={(e) => setForm({ ...form, customerPhone: e.target.value })} placeholder="0911 234567" /></div>
<div className="space-y-2"><Label>Customer Name</Label><Input value={form.customerName} onChange={(e) => setForm({ ...form, customerName: e.target.value })} placeholder="Optional" /></div>
<div className="space-y-2"><Label>{t('dashboard.customerPhone', 'Customer Phone *')}</Label><Input value={form.customerPhone} onChange={(e) => setForm({ ...form, customerPhone: e.target.value })} placeholder="0911 234567" /></div>
<div className="space-y-2"><Label>{t('dashboard.customerName', 'Customer Name')}</Label><Input value={form.customerName} onChange={(e) => setForm({ ...form, customerName: e.target.value })} placeholder={t('common.optional', 'Optional')} /></div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2"><Label>Duration (minutes)</Label><Input type="number" min="0" value={form.durationMinutes} onChange={(e) => setForm({ ...form, durationMinutes: e.target.value })} placeholder="0" /></div>
<div className="space-y-2"><Label>{t('dashboard.durationMinutes', 'Duration (minutes)')}</Label><Input type="number" min="0" value={form.durationMinutes} onChange={(e) => setForm({ ...form, durationMinutes: e.target.value })} placeholder="0" /></div>
<div className="space-y-2">
<Label>Outcome</Label>
<Label>{t('dashboard.outcome', 'Outcome')}</Label>
<Select value={form.status} onValueChange={(v) => setForm({ ...form, status: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="missed">Missed</SelectItem>
<SelectItem value="abandoned">Abandoned</SelectItem>
<SelectItem value="completed">{t('dashboard.completed', 'Completed')}</SelectItem>
<SelectItem value="missed">{t('dashboard.missed', 'Missed')}</SelectItem>
<SelectItem value="abandoned">{t('dashboard.abandoned', 'Abandoned')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2"><Label>Notes</Label><Input value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} placeholder="Optional" /></div>
<div className="space-y-2"><Label>{t('dashboard.notes', 'Notes')}</Label><Input value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} placeholder={t('common.optional', 'Optional')} /></div>
</div>
<div className="flex gap-3 justify-end">
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="outline" onClick={() => setOpen(false)}>{t('common.cancel', 'Cancel')}</Button>
<Button className="bg-[#00BDC3] hover:bg-[#009EA3] text-white" onClick={submit} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null} Save Call
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null} {t('dashboard.saveCall', 'Save Call')}
</Button>
</div>
</DialogContent>
Expand Down
Loading