feat(web): Schedule-First redesign
- Top header bar + narrow sidebar layout - Dashboard: calendar-style schedule with timeline cards - Clients: card-based layout with avatar initials and pet count - Services: grid cards with emoji indicators and pricing - Bookings: timeline cards with time slots, status chips, inline actions - Invoices: cards with status badges, action buttons - All pages: search bars, empty states, modal forms - Consistent color system: indigo primary, amber alerts, gray hierarchy
This commit is contained in:
+163
-88
@@ -1,123 +1,198 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Users, Scissors, Calendar, FileText, MessageCircle,
|
||||
ClipboardList, TrendingUp, AlertCircle,
|
||||
Users, Scissors, Calendar, FileText, DollarSign,
|
||||
TrendingUp, AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
interface Stat { label: string; value: number; icon: any; color: string; path: string }
|
||||
interface Stat { label: string; value: number; sublabel: string; icon: any; color: string; path: string }
|
||||
|
||||
export default function Dashboard() {
|
||||
const stats: Stat[] = [
|
||||
{ label: 'Active Clients', value: 0, icon: Users, color: 'bg-blue-500', path: '/clients' },
|
||||
{ label: 'Services', value: 0, icon: Scissors, color: 'bg-emerald-500', path: '/services' },
|
||||
{ label: 'Pending Bookings', value: 0, icon: Calendar, color: 'bg-amber-500', path: '/bookings' },
|
||||
{ label: 'Unpaid Invoices', value: 0, icon: FileText, color: 'bg-red-500', path: '/invoices' },
|
||||
{ label: 'Messages', value: 0, icon: MessageCircle, color: 'bg-purple-500', path: '/messages' },
|
||||
{ label: 'Active Clients', value: 0, sublabel: '+12 this month', icon: Users, color: 'from-blue-500 to-blue-600', path: '/clients' },
|
||||
{ label: 'Scheduled', value: 0, sublabel: '3 today', icon: Calendar, color: 'from-amber-500 to-amber-600', path: '/bookings' },
|
||||
{ label: 'Unpaid', value: 0, sublabel: '4 invoices', icon: FileText, color: 'from-red-500 to-red-600', path: '/invoices' },
|
||||
{ label: 'Revenue', value: 0, sublabel: 'This month', icon: DollarSign, color: 'from-emerald-500 to-emerald-600', path: '/invoices' },
|
||||
];
|
||||
|
||||
// Fetch stats
|
||||
const [statsData, setStatsData] = useState<any[]>([]);
|
||||
const [statsData, setStatsData] = useState<Stat[]>([]);
|
||||
const [recentBookings, setRecentBookings] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(`${API_URL}/clients?limit=1`).catch(() => ({ ok: false })),
|
||||
fetch(`${API_URL}/bookings?status=scheduled`).catch(() => ({ ok: false })),
|
||||
fetch(`${API_URL}/invoices`).catch(() => ({ ok: false })),
|
||||
fetch(`${API_URL}/services`).catch(() => ({ ok: false })),
|
||||
]).then(async ([c, b, i, s]) => {
|
||||
const [clients, bookings, invoices, services] = await Promise.all([
|
||||
(c as Response).json().catch(() => ({ total: 0 })),
|
||||
(b as Response).json().catch(() => ({ items: [] })),
|
||||
(i as Response).json().catch(() => ({ items: [] })),
|
||||
(s as Response).json().catch(() => ({ items: [] })),
|
||||
]);
|
||||
fetch(`${API_URL}/clients?limit=1`).then(r => r.json().catch(() => ({ total: 0 }))),
|
||||
fetch(`${API_URL}/bookings?status=scheduled`).then(r => r.json().catch(() => ({ items: [] }))),
|
||||
fetch(`${API_URL}/invoices`).then(r => r.json().catch(() => ({ items: [] }))),
|
||||
fetch(`${API_URL}/services`).then(r => r.json().catch(() => ({ items: [] }))),
|
||||
fetch(`${API_URL}/bookings?limit=5`).then(r => r.json().catch(() => ({ items: [] }))),
|
||||
]).then(([clients, bookings, invoices, services, recent]) => {
|
||||
const paidCount = (invoices.items || []).filter((x: any) => x.status !== 'paid').length;
|
||||
const unpaidTotal = (invoices.items || []).reduce((sum: number, x: any) => sum + (x.total || 0), 0);
|
||||
setStatsData([
|
||||
{ ...stats[0], value: clients.total || 0 },
|
||||
{ ...stats[1], value: services.items?.length || services.length || 0 },
|
||||
{ ...stats[2], value: bookings.items?.filter((x: any) => x.status === 'scheduled').length || 0 },
|
||||
{ ...stats[3], value: (invoices.items || []).filter((x: any) => x.status !== 'paid').length || 0 },
|
||||
{ ...stats[4], value: 0 },
|
||||
{ ...stats[1], value: bookings.items?.filter((x: any) => x.status === 'scheduled').length || 0 },
|
||||
{ ...stats[2], value: paidCount, sublabel: `${paidCount} invoices` },
|
||||
{ ...stats[3], value: unpaidTotal / 100 || 0, sublabel: 'This month' },
|
||||
]);
|
||||
});
|
||||
setRecentBookings(recent.items || []);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const statusColors: Record<string, { bg: string; text: string }> = {
|
||||
scheduled: { bg: 'bg-amber-100', text: 'text-amber-700' },
|
||||
confirmed: { bg: 'bg-blue-100', text: 'text-blue-700' },
|
||||
in_progress: { bg: 'bg-purple-100', text: 'text-purple-700' },
|
||||
completed: { bg: 'bg-green-100', text: 'text-green-700' },
|
||||
cancelled: { bg: 'bg-red-100', text: 'text-red-700' },
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-800 mb-6">Dashboard</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
|
||||
{/* Top Bar */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight text-slate-800">Dashboard</h1>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/bookings" className="bg-indigo-600 text-white text-sm font-semibold px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
New Booking
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
{statsData.map((s, i) => (
|
||||
<Link key={i} to={s.path} className="block">
|
||||
<div className="bg-white rounded-xl p-6 border border-slate-200 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<s.icon className={`w-8 h-8 ${s.color.replace('bg-', 'text-')}`} />
|
||||
<span className="text-2xl font-bold text-slate-800">{s.value}</span>
|
||||
<div className="bg-white rounded-xl p-5 border border-gray-200 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${s.color} flex items-center justify-center`}>
|
||||
<s.icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">{s.label}</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 font-medium">{s.label}</p>
|
||||
<div className="text-2xl font-bold text-slate-800">
|
||||
{s.label === 'Revenue' ? `$${s.value}` : s.value}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{s.sublabel}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl p-6 border border-slate-200">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Quick Actions</h2>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ label: 'Add New Client', path: '/clients', icon: Users },
|
||||
{ label: 'Create Booking', path: '/bookings', icon: Calendar },
|
||||
{ label: 'Generate Invoice', path: '/invoices', icon: FileText },
|
||||
{ label: 'New Message', path: '/messages', icon: MessageCircle },
|
||||
].map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Link key={action.path} to={action.path} className="flex items-center gap-3 p-3 rounded-lg bg-slate-50 hover:bg-indigo-50 transition-colors">
|
||||
<Icon className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-sm font-medium text-slate-700">{action.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Two Column: Schedule + Alerts */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Left: Today's Schedule (2 cols) */}
|
||||
<div className="col-span-2 bg-white rounded-xl border border-gray-200">
|
||||
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 className="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-indigo-600" />
|
||||
Today's Schedule
|
||||
</h2>
|
||||
<Link to="/bookings" className="text-xs text-indigo-600 hover:text-indigo-700 font-medium">
|
||||
View Full Calendar →
|
||||
</Link>
|
||||
</div>
|
||||
{recentBookings.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="text-4xl mb-3">📅</div>
|
||||
<p className="text-sm text-gray-400 font-medium">No bookings today</p>
|
||||
<Link to="/bookings" className="text-xs text-indigo-600 hover:text-indigo-700 font-medium mt-2 inline-block">
|
||||
Create your first booking →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{recentBookings.map((booking) => {
|
||||
const sc = statusColors[booking.status] || statusColors.scheduled;
|
||||
return (
|
||||
<Link
|
||||
key={booking.id}
|
||||
to="/bookings"
|
||||
className="p-4 flex items-center gap-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="text-xs font-mono text-gray-400 w-20">
|
||||
{booking.startAt ? new Date(booking.startAt).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }) : '—'}
|
||||
</div>
|
||||
<div className="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center text-sm">
|
||||
🐾
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-slate-800 truncate">
|
||||
{booking.client?.firstName} {booking.client?.lastName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{booking.service?.name} · {booking.assignedStaff || 'Unassigned'}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2.5 py-1 rounded-full text-[11px] font-semibold ${sc.bg} ${sc.text}`}>
|
||||
{booking.status}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-6 border border-slate-200">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Recent Bookings</h2>
|
||||
<RecentBookings />
|
||||
|
||||
{/* Right: Quick Actions + Alerts */}
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h2 className="text-sm font-bold text-slate-800">Quick Actions</h2>
|
||||
</div>
|
||||
<div className="p-3 space-y-1.5">
|
||||
<Link to="/clients" className="flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg hover:bg-gray-50 text-gray-600 transition-colors">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||
<Users className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
Add Client
|
||||
</Link>
|
||||
<Link to="/bookings" className="flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg hover:bg-gray-50 text-gray-600 transition-colors">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-100 flex items-center justify-center">
|
||||
<Calendar className="w-4 h-4 text-amber-600" />
|
||||
</div>
|
||||
Create Booking
|
||||
</Link>
|
||||
<Link to="/invoices" className="flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg hover:bg-gray-50 text-gray-600 transition-colors">
|
||||
<div className="w-8 h-8 rounded-lg bg-green-100 flex items-center justify-center">
|
||||
<FileText className="w-4 h-4 text-green-600" />
|
||||
</div>
|
||||
Generate Invoice
|
||||
</Link>
|
||||
<Link to="/messages" className="flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg hover:bg-gray-50 text-gray-600 transition-colors">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center">
|
||||
<MessageCircle className="w-4 h-4 text-purple-600" />
|
||||
</div>
|
||||
Send Message
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 rounded-xl border border-amber-200 p-4">
|
||||
<h2 className="text-sm font-bold text-amber-800 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
Alerts
|
||||
</h2>
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-xs text-amber-600/80 flex items-start gap-2">
|
||||
<span className="mt-1 w-1.5 h-1.5 rounded-full bg-amber-400 flex-shrink-0" />
|
||||
2 invoices overdue
|
||||
</p>
|
||||
<p className="text-xs text-amber-600/80 flex items-start gap-2">
|
||||
<span className="mt-1 w-1.5 h-1.5 rounded-full bg-amber-400 flex-shrink-0" />
|
||||
1 pet vaccination expiring
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentBookings() {
|
||||
const [bookings, setBookings] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
fetch(`${API_URL}/bookings?limit=5`)
|
||||
.then(r => r.json())
|
||||
.then(d => setBookings(d.items || []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (bookings.length === 0) {
|
||||
return <p className="text-slate-400 text-sm">No bookings yet. Create one to get started.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{bookings.map((b) => (
|
||||
<div key={b.id} className="flex items-center justify-between p-3 rounded-lg bg-slate-50">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{b.client?.firstName} {b.client?.lastName}</p>
|
||||
<p className="text-xs text-slate-400">{b.service?.name} · {b.startAt ? new Date(b.startAt).toLocaleDateString() : ''}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
b.status === 'scheduled' ? 'bg-amber-100 text-amber-700' :
|
||||
b.status === 'confirmed' ? 'bg-blue-100 text-blue-700' :
|
||||
b.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{b.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user