feat: Full petmaster CRM with NestJS API and React frontend
- Client management (CRUD with JSONB custom fields) - Pet profiles - Service catalog with per-client pricing - Booking calendar with recurring appointments - Invoicing with Stripe integration - Real-time messaging (WebSocket) - Self-booking portal - Staff assignment - Docker multi-stage builds - Prisma schema + seed
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Users, Scissors, Calendar, FileText, MessageCircle,
|
||||
ClipboardList, TrendingUp, AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
interface Stat { label: string; value: number; 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' },
|
||||
];
|
||||
|
||||
// Fetch stats
|
||||
const [statsData, setStatsData] = 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: [] })),
|
||||
]);
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
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">
|
||||
{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>
|
||||
<p className="text-sm text-slate-500 font-medium">{s.label}</p>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</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 />
|
||||
</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