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:
Robert Perez
2026-07-11 00:17:49 +00:00
commit 8d742518f8
65 changed files with 3365 additions and 0 deletions
+123
View File
@@ -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>
);
}