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:
Robert Perez
2026-07-14 05:13:04 +00:00
parent cdaf5780e5
commit d8a4e43bcc
31 changed files with 1801 additions and 400 deletions
+163 -88
View File
@@ -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>
);
}