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 @@
|
||||
/* App styles */
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
import './App.css';
|
||||
import Layout from './components/Layout';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Clients from './pages/Clients';
|
||||
import Services from './pages/Services';
|
||||
import Bookings from './pages/Bookings';
|
||||
import Invoices from './pages/Invoices';
|
||||
import Messages from './pages/Messages';
|
||||
import SelfBooking from './pages/SelfBooking';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{ index: true, element: <Dashboard /> },
|
||||
{ path: 'clients', element: <Clients /> },
|
||||
{ path: 'services', element: <Services /> },
|
||||
{ path: 'bookings', element: <Bookings /> },
|
||||
{ path: 'invoices', element: <Invoices /> },
|
||||
{ path: 'messages', element: <Messages /> },
|
||||
{ path: 'self-booking', element: <SelfBooking /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
function App() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Outlet, NavLink, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, Users, Scissors, Calendar, FileText, MessageCircle,
|
||||
ClipboardList,
|
||||
} from 'lucide-react';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ path: '/clients', label: 'Clients', icon: Users },
|
||||
{ path: '/services', label: 'Services', icon: Scissors },
|
||||
{ path: '/bookings', label: 'Bookings', icon: Calendar },
|
||||
{ path: '/invoices', label: 'Invoices', icon: FileText },
|
||||
{ path: '/messages', label: 'Messages', icon: MessageCircle },
|
||||
{ path: '/self-booking', label: 'Self-Booking', icon: ClipboardList },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<aside className="fixed left-0 top-0 h-screen w-64 bg-white border-r border-slate-200 overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-indigo-600 flex items-center gap-2">
|
||||
<span className="text-3xl">🐾</span> Petmaster
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-1">CRM Dashboard</p>
|
||||
</div>
|
||||
<nav className="px-3">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = location.pathname === item.path;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-indigo-50 text-indigo-700'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="ml-64 p-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3000';
|
||||
@@ -0,0 +1,29 @@
|
||||
import { API_URL } from '../config';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
interface StatCard { label: string; value: string | number; color: string; icon: string }
|
||||
|
||||
async function fetchStats(): Promise<StatCard[]> {
|
||||
const [clientsRes, bookingsRes, invoicesRes, messagesRes] = await Promise.all([
|
||||
fetch(`${API_URL}/clients?limit=1`).catch(() => ({ ok: false })),
|
||||
fetch(`${API_URL}/bookings`).catch(() => ({ ok: false })),
|
||||
fetch(`${API_URL}/invoices`).catch(() => ({ ok: false })),
|
||||
fetch(`${API_URL}/services`).catch(() => ({ ok: false })),
|
||||
]);
|
||||
|
||||
const clients = await (clientsRes as Response).json().catch(() => ({ total: 0 }));
|
||||
const bookings = await (bookingsRes as Response).json().catch(() => ({ items: [] }));
|
||||
const invoices = await (invoicesRes as Response).json().catch(() => ({ total: 0 }));
|
||||
const services = await (invoicesRes as Response).json().catch(() => ({ items: [] }));
|
||||
|
||||
return [
|
||||
{ label: 'Active Clients', value: clients.total || 0, color: 'bg-blue-500', icon: '👥' },
|
||||
{ label: 'Pending Bookings', value: bookings.items?.filter((b: any) => b.status === 'scheduled').length || 0, color: 'bg-amber-500', icon: '📅' },
|
||||
{ label: 'Unpaid Invoices', value: invoices.total || 0, color: 'bg-red-500', icon: '💰' },
|
||||
{ label: 'Service Catalog', value: services.items?.length || services.length || 0, color: 'bg-green-500', icon: '✂️' },
|
||||
];
|
||||
}
|
||||
|
||||
export function useStats() {
|
||||
return useQuery({ queryKey: ['stats'], queryFn: fetchStats });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-primary: #6366f1;
|
||||
--color-primary-hover: #4f46e5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Calendar, User, Scissors, MapPin } from 'lucide-react';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
interface Booking {
|
||||
id: string; clientId: string; client?: any; serviceId: string; service?: any;
|
||||
startAt: string; endAt: string; status: string; notes: string;
|
||||
assignedStaff: string | null; priceOverride: number | null;
|
||||
recurringRule: any; createdAt: string;
|
||||
}
|
||||
|
||||
export default function Bookings() {
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [services, setServices] = useState<any[]>([]);
|
||||
const [clients, setClients] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [selectedDate, setSelectedDate] = useState('');
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
clientId: '', serviceId: '', startAt: '', endAt: '',
|
||||
assignedStaff: '', notes: '',
|
||||
});
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [bRes, sRes, cRes] = await Promise.all([
|
||||
fetch(`${API_URL}/bookings${filterStatus ? `?status=${filterStatus}` : ''}${selectedDate ? `&startDate=${selectedDate}` : ''}`),
|
||||
fetch(`${API_URL}/services`),
|
||||
fetch(`${API_URL}/clients?limit=100`),
|
||||
]);
|
||||
const bData = await bRes.json();
|
||||
const sData = await sRes.json();
|
||||
const cData = await cRes.json();
|
||||
setBookings(bData.items || []);
|
||||
setServices(sData || []);
|
||||
setClients(cData.items || cData || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchData(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/bookings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
if (res.ok) {
|
||||
setShowForm(false);
|
||||
fetchData();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save booking:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (id: string, status: string) => {
|
||||
try {
|
||||
await fetch(`${API_URL}/bookings/${id}/status`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
console.error('Failed to update status:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignStaff = async (id: string, staffId: string) => {
|
||||
try {
|
||||
await fetch(`${API_URL}/bookings/${id}/assign`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ staffId }),
|
||||
});
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
console.error('Failed to assign staff:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
scheduled: 'bg-amber-100 text-amber-700',
|
||||
confirmed: 'bg-blue-100 text-blue-700',
|
||||
in_progress: 'bg-purple-100 text-purple-700',
|
||||
completed: 'bg-green-100 text-green-700',
|
||||
cancelled: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold text-slate-800">Bookings</h1>
|
||||
<button onClick={() => setShowForm(true)}
|
||||
className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700">
|
||||
<Plus className="w-4 h-4" /> New Booking
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-slate-400" />
|
||||
<input type="date" value={selectedDate} onChange={e => setSelectedDate(e.target.value)}
|
||||
className="border rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)}
|
||||
className="border rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="text-center py-8">Loading...</div> : (
|
||||
<div className="space-y-3">
|
||||
{bookings.map(booking => (
|
||||
<div key={booking.id} className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">
|
||||
{booking.client?.firstName} {booking.client?.lastName}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 flex items-center gap-2">
|
||||
<Scissors className="w-3 h-3" />
|
||||
{booking.service?.name} · {booking.assignedStaff ? `Staff: ${booking.assignedStaff}` : 'Unassigned'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
📅 {booking.startAt ? new Date(booking.startAt).toLocaleString() : ''} - {booking.endAt ? new Date(booking.endAt).toLocaleString() : ''}
|
||||
</p>
|
||||
{booking.notes && <p className="text-sm text-slate-400 mt-1">📝 {booking.notes}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusColors[booking.status] || 'bg-slate-100'}`}>
|
||||
{booking.status}
|
||||
</span>
|
||||
<select
|
||||
value={booking.status}
|
||||
onChange={e => handleStatusChange(booking.id, e.target.value)}
|
||||
className="text-xs border rounded px-2 py-1"
|
||||
>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{!booking.assignedStaff && (
|
||||
<div className="mt-2 pt-2 border-t border-slate-100">
|
||||
<select
|
||||
value={formData.assignedStaff || ''}
|
||||
onChange={e => handleAssignStaff(booking.id, e.target.value)}
|
||||
className="text-xs border rounded px-2 py-1"
|
||||
>
|
||||
<option value="">Assign Staff...</option>
|
||||
<option value="staff-1">Staff Member 1</option>
|
||||
<option value="staff-2">Staff Member 2</option>
|
||||
<option value="staff-3">Staff Member 3</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{bookings.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">No bookings found. Create one to get started.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-lg">
|
||||
<h2 className="text-xl font-bold text-slate-800 mb-4">New Booking</h2>
|
||||
<div className="space-y-4">
|
||||
<select value={formData.clientId} onChange={e => setFormData({ ...formData, clientId: e.target.value })}
|
||||
className="border rounded-lg px-3 py-2 w-full">
|
||||
<option value="">Select Client...</option>
|
||||
{clients.map(c => <option key={c.id} value={c.id}>{c.firstName} {c.lastName}</option>)}
|
||||
</select>
|
||||
<select value={formData.serviceId} onChange={e => setFormData({ ...formData, serviceId: e.target.value })}
|
||||
className="border rounded-lg px-3 py-2 w-full">
|
||||
<option value="">Select Service...</option>
|
||||
{services.map(s => <option key={s.id} value={s.id}>{s.name} - ${s.basePrice}</option>)}
|
||||
</select>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<input value={formData.startAt} onChange={e => setFormData({ ...formData, startAt: e.target.value })}
|
||||
type="datetime-local" className="border rounded-lg px-3 py-2 w-full" />
|
||||
<input value={formData.endAt} onChange={e => setFormData({ ...formData, endAt: e.target.value })}
|
||||
type="datetime-local" className="border rounded-lg px-3 py-2 w-full" />
|
||||
</div>
|
||||
<input value={formData.assignedStaff} onChange={e => setFormData({ ...formData, assignedStaff: e.target.value })}
|
||||
placeholder="Staff name (optional)" className="border rounded-lg px-3 py-2 w-full" />
|
||||
<textarea value={formData.notes} onChange={e => setFormData({ ...formData, notes: e.target.value })}
|
||||
placeholder="Notes (optional)" rows={3} className="border rounded-lg px-3 py-2 w-full" />
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setShowForm(false)} className="flex-1 px-4 py-2 border rounded-lg">Cancel</button>
|
||||
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg">Create Booking</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Search, User, Scissors, Calendar } from 'lucide-react';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
interface Client { id: string; firstName: string; lastName: string; email: string; phone?: string; isActive: boolean }
|
||||
|
||||
export default function Clients() {
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingClient, setEditingClient] = useState<Client | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '', lastName: '', email: '', phone: '', address: '', notes: ''
|
||||
});
|
||||
|
||||
const fetchClients = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/clients${search ? `?search=${search}` : ''}`);
|
||||
const data = await res.json();
|
||||
setClients(data.items || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch clients:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchClients(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
const url = editingClient ? `${API_URL}/clients/${editingClient.id}` : `${API_URL}/clients`;
|
||||
const method = editingClient ? 'PUT' : 'POST';
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
if (res.ok) {
|
||||
setShowForm(false);
|
||||
setEditingClient(null);
|
||||
fetchClients();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save client:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Delete this client?')) return;
|
||||
try {
|
||||
await fetch(`${API_URL}/clients/${id}`, { method: 'DELETE' });
|
||||
fetchClients();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete client:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold text-slate-800">Clients</h1>
|
||||
<button onClick={() => { setShowForm(true); setEditingClient(null); }}
|
||||
className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700">
|
||||
<Plus className="w-4 h-4" /> New Client
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-4">
|
||||
<div className="flex items-center gap-2 max-w-md">
|
||||
<Search className="w-4 h-4 text-slate-400" />
|
||||
<input value={search} onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search clients..." className="flex-1 border-0 focus:ring-0 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="text-center py-8">Loading...</div> : (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-slate-600">Name</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-slate-600">Email</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-slate-600">Phone</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-slate-600">Status</th>
|
||||
<th className="text-right px-4 py-3 text-sm font-medium text-slate-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clients.map(client => (
|
||||
<tr key={client.id} className="border-t border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-indigo-600" />
|
||||
</div>
|
||||
<span className="font-medium text-slate-800">{client.firstName} {client.lastName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{client.email}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{client.phone || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${client.isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{client.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button onClick={() => { setEditingClient(client); setFormData({ firstName: client.firstName, lastName: client.lastName, email: client.email, phone: client.phone || '', address: '', notes: '' }); setShowForm(true); }}
|
||||
className="text-indigo-600 text-sm hover:underline mr-3">Edit</button>
|
||||
<button onClick={() => handleDelete(client.id)} className="text-red-600 text-sm hover:underline">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{clients.length === 0 && (
|
||||
<div className="text-center py-8 text-slate-400">No clients found. Add your first client!</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold text-slate-800 mb-4">{editingClient ? 'Edit Client' : 'New Client'}</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<input value={formData.firstName} onChange={e => setFormData({ ...formData, firstName: e.target.value })}
|
||||
placeholder="First name" className="border rounded-lg px-3 py-2" />
|
||||
<input value={formData.lastName} onChange={e => setFormData({ ...formData, lastName: e.target.value })}
|
||||
placeholder="Last name" className="border rounded-lg px-3 py-2" />
|
||||
</div>
|
||||
<input value={formData.email} onChange={e => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="Email" type="email" className="border rounded-lg px-3 py-2 w-full" />
|
||||
<input value={formData.phone} onChange={e => setFormData({ ...formData, phone: e.target.value })}
|
||||
placeholder="Phone" className="border rounded-lg px-3 py-2 w-full" />
|
||||
<textarea value={formData.notes} onChange={e => setFormData({ ...formData, notes: e.target.value })}
|
||||
placeholder="Notes" rows={3} className="border rounded-lg px-3 py-2 w-full" />
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setShowForm(false)} className="flex-1 px-4 py-2 border border-slate-300 rounded-lg text-slate-600">Cancel</button>
|
||||
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, FileText, DollarSign, Check, CreditCard } from 'lucide-react';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
interface Invoice {
|
||||
id: string; invoiceNumber: string; clientId: string; client?: any;
|
||||
subtotal: number; tax: number; total: number; status: string;
|
||||
dueDate: string; createdAt: string; items: any[];
|
||||
}
|
||||
|
||||
export default function Invoices() {
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [clientId, setClientId] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
|
||||
useEffect(() => { fetchInvoices(); }, []);
|
||||
|
||||
const fetchInvoices = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/invoices?limit=50`);
|
||||
const data = await res.json();
|
||||
setInvoices(data.items || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch invoices:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!clientId || !description || !amount) return;
|
||||
const tax = Number(amount) * 0.1;
|
||||
const res = await fetch(`${API_URL}/invoices`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
items: [{ description, quantity: 1, unitPrice: Number(amount), amount: Number(amount) }],
|
||||
}),
|
||||
});
|
||||
if (res.ok) { setShowCreateForm(false); fetchInvoices(); }
|
||||
};
|
||||
|
||||
const handleMarkPaid = async (id: string) => {
|
||||
await fetch(`${API_URL}/invoices/${id}/pay`, { method: 'PUT', headers: { 'Content-Type': 'application/json' } });
|
||||
fetchInvoices();
|
||||
};
|
||||
|
||||
const handleStripe = async (id: string) => {
|
||||
const res = await fetch(`${API_URL}/invoices/${id}/stripe`, { method: 'POST', headers: { 'Content-Type': 'application/json' } });
|
||||
const data = await res.json();
|
||||
alert(`Payment Intent: ${data.paymentIntentId}`);
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: 'bg-slate-100 text-slate-700',
|
||||
sent: 'bg-blue-100 text-blue-700',
|
||||
paid: 'bg-green-100 text-green-700',
|
||||
overdue: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold text-slate-800">Invoices</h1>
|
||||
<button onClick={() => setShowCreateForm(true)}
|
||||
className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700">
|
||||
<Plus className="w-4 h-4" /> New Invoice
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="text-center py-8">Loading...</div> : (
|
||||
<div className="space-y-3">
|
||||
{invoices.map(invoice => (
|
||||
<div key={invoice.id} className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-xl flex items-center justify-center">
|
||||
<FileText className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">{invoice.invoiceNumber}</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{invoice.client?.firstName} {invoice.client?.lastName} · Due: {invoice.dueDate ? new Date(invoice.dueDate).toLocaleDateString() : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xl font-bold text-slate-800">${invoice.total.toFixed(2)}</span>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusColors[invoice.status] || 'bg-slate-100'}`}>
|
||||
{invoice.status}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{invoice.status !== 'paid' && (
|
||||
<button onClick={() => handleMarkPaid(invoice.id)}
|
||||
className="text-green-600 hover:underline text-xs flex items-center gap-1">
|
||||
<Check className="w-3 h-3" /> Mark Paid
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => handleStripe(invoice.id)}
|
||||
className="text-blue-600 hover:underline text-xs flex items-center gap-1">
|
||||
<CreditCard className="w-3 h-3" /> Stripe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{invoices.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">No invoices yet. Create your first one!</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreateForm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold text-slate-800 mb-4">New Invoice</h2>
|
||||
<div className="space-y-4">
|
||||
<input value={clientId} onChange={e => setClientId(e.target.value)}
|
||||
placeholder="Client ID" className="border rounded-lg px-3 py-2 w-full" />
|
||||
<input value={description} onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Description" className="border rounded-lg px-3 py-2 w-full" />
|
||||
<input value={amount} onChange={e => setAmount(e.target.value)}
|
||||
placeholder="Amount ($)" type="number" step="0.01" className="border rounded-lg px-3 py-2 w-full" />
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setShowCreateForm(false)} className="flex-1 px-4 py-2 border rounded-lg">Cancel</button>
|
||||
<button onClick={handleCreate} className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Send, User, Bot } from 'lucide-react';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
interface Message {
|
||||
id: string; clientId: string; content: string; isFromStaff: boolean;
|
||||
createdAt: string; readAt: string | null;
|
||||
}
|
||||
|
||||
export default function Messages() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [clientId, setClientId] = useState('');
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const staffId = 'staff-1'; // In production, this would come from auth
|
||||
|
||||
useEffect(() => {
|
||||
if (clientId) fetchMessages();
|
||||
}, [clientId]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const fetchMessages = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/messages/conversation/${clientId}?staffId=${staffId}`);
|
||||
const data = await res.json();
|
||||
setMessages(data || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch messages:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!newMessage.trim() || !clientId) return;
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/ws/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: newMessage,
|
||||
clientId,
|
||||
staffId,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setNewMessage('');
|
||||
fetchMessages();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const clients = [
|
||||
{ id: 'client-1', name: 'Alice Johnson' },
|
||||
{ id: 'client-2', name: 'Bob Smith' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex gap-6 h-[calc(100vh-8rem)]">
|
||||
<div className="w-72 bg-white rounded-xl border border-slate-200 p-4 overflow-y-auto">
|
||||
<h2 className="text-sm font-semibold text-slate-600 mb-4 uppercase tracking-wide">Conversations</h2>
|
||||
<div className="space-y-1">
|
||||
{clients.map(client => (
|
||||
<button key={client.id} onClick={() => { setClientId(client.id); }}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
clientId === client.id ? 'bg-indigo-50 text-indigo-700 font-medium' : 'text-slate-600 hover:bg-slate-50'
|
||||
}`}>
|
||||
{client.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-white rounded-xl border border-slate-200 flex flex-col">
|
||||
<div className="p-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-800">
|
||||
{clients.find(c => c.id === clientId)?.name || 'Select a conversation'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{loading ? <div className="text-center text-slate-400">Loading...</div> : (
|
||||
messages.map(msg => (
|
||||
<div key={msg.id} className={`flex ${msg.isFromStaff ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`flex items-start gap-2 max-w-[70%] ${msg.isFromStaff ? 'flex-row-reverse' : ''}`}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
msg.isFromStaff ? 'bg-indigo-100' : 'bg-slate-100'
|
||||
}`}>
|
||||
{msg.isFromStaff ? <Bot className="w-4 h-4 text-indigo-600" /> : <User className="w-4 h-4 text-slate-600" />}
|
||||
</div>
|
||||
<div className={`rounded-xl px-4 py-2 ${
|
||||
msg.isFromStaff
|
||||
? 'bg-indigo-600 text-white rounded-br-sm'
|
||||
: 'bg-slate-100 text-slate-800 rounded-bl-sm'
|
||||
}`}>
|
||||
<p className="text-sm">{msg.content}</p>
|
||||
<p className={`text-xs mt-1 ${msg.isFromStaff ? 'text-indigo-200' : 'text-slate-400'}`}>
|
||||
{new Date(msg.createdAt).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-slate-100">
|
||||
<div className="flex gap-2">
|
||||
<input value={newMessage} onChange={e => setNewMessage(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSend()}
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 border rounded-lg px-4 py-2"
|
||||
disabled={!clientId} />
|
||||
<button onClick={handleSend} disabled={!newMessage.trim() || !clientId}
|
||||
className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-50">
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Calendar, User, Pet, Scissors, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
interface Service { id: string; name: string; description: string; duration: number; basePrice: number }
|
||||
|
||||
export default function SelfBooking() {
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '', lastName: '', email: '', phone: '',
|
||||
serviceId: '', requestedDate: '', petName: '', species: '', breed: '', notes: '',
|
||||
});
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [successMsg, setSuccessMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_URL}/self-booking/services`)
|
||||
.then(r => r.json())
|
||||
.then(setServices)
|
||||
.catch(() => setError('Failed to load services'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccessMsg('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/self-booking/request`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setSuccessMsg('Your service request has been submitted! We will contact you to confirm.');
|
||||
setSubmitted(true);
|
||||
} else {
|
||||
setError(data.message || 'Failed to submit request');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFormData({
|
||||
firstName: '', lastName: '', email: '', phone: '',
|
||||
serviceId: '', requestedDate: '', petName: '', species: '', breed: '', notes: '',
|
||||
});
|
||||
setSubmitted(false);
|
||||
setSuccessMsg('');
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-center py-12">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-slate-800 mb-2">Book an Appointment</h1>
|
||||
<p className="text-slate-500">Fill out the form below and we'll confirm your appointment</p>
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<div className="bg-white rounded-xl border border-green-200 p-8 text-center">
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-bold text-slate-800 mb-2">Request Submitted!</h2>
|
||||
<p className="text-slate-500 mb-4">{successMsg}</p>
|
||||
<button onClick={handleReset} className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700">
|
||||
Book Another
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-slate-200 p-6 space-y-6">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4" /> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">Your Information</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<input value={formData.firstName} onChange={e => setFormData({ ...formData, firstName: e.target.value })}
|
||||
placeholder="First name" required className="border rounded-lg px-3 py-2" />
|
||||
<input value={formData.lastName} onChange={e => setFormData({ ...formData, lastName: e.target.value })}
|
||||
placeholder="Last name" required className="border rounded-lg px-3 py-2" />
|
||||
<input value={formData.email} onChange={e => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="Email" type="email" required className="border rounded-lg px-3 py-2" />
|
||||
<input value={formData.phone} onChange={e => setFormData({ ...formData, phone: e.target.value })}
|
||||
placeholder="Phone (optional)" className="border rounded-lg px-3 py-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">Select Service</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{services.map(s => (
|
||||
<div key={s.id}
|
||||
onClick={() => setFormData({ ...formData, serviceId: s.id })}
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
formData.serviceId === s.id ? 'border-indigo-500 bg-indigo-50' : 'border-slate-200 hover:border-slate-300'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<Scissors className="w-4 h-4 text-indigo-600" />
|
||||
<span className="font-medium text-slate-800">{s.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">{s.description}</p>
|
||||
<p className="text-sm font-bold text-indigo-600 mt-1">${s.basePrice.toFixed(2)} · {s.duration} min</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">Pet Information</h3>
|
||||
<div className="space-y-4">
|
||||
<input value={formData.petName} onChange={e => setFormData({ ...formData, petName: e.target.value })}
|
||||
placeholder="Pet name (optional)" className="border rounded-lg px-3 py-2 w-full" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<input value={formData.species} onChange={e => setFormData({ ...formData, species: e.target.value })}
|
||||
placeholder="Species (e.g. Dog, Cat)" className="border rounded-lg px-3 py-2 w-full" />
|
||||
<input value={formData.breed} onChange={e => setFormData({ ...formData, breed: e.target.value })}
|
||||
placeholder="Breed (optional)" className="border rounded-lg px-3 py-2 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">Preferred Date & Time</h3>
|
||||
<input value={formData.requestedDate} onChange={e => setFormData({ ...formData, requestedDate: e.target.value })}
|
||||
type="datetime-local" required className="border rounded-lg px-3 py-2 w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">Additional Notes</h3>
|
||||
<textarea value={formData.notes} onChange={e => setFormData({ ...formData, notes: e.target.value })}
|
||||
placeholder="Any special requirements..." rows={3}
|
||||
className="border rounded-lg px-3 py-2 w-full" />
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
className="w-full bg-indigo-600 text-white py-3 rounded-lg font-semibold hover:bg-indigo-700">
|
||||
Submit Request
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Scissors, DollarSign, Clock } from 'lucide-react';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
interface Service { id: string; name: string; description: string; duration: number; basePrice: number }
|
||||
|
||||
export default function Services() {
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '', description: '', duration: 30, basePrice: 0
|
||||
});
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/services`);
|
||||
const data = await res.json();
|
||||
setServices(data || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch services:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchServices(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/services`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
if (res.ok) {
|
||||
setShowForm(false);
|
||||
fetchServices();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save service:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Delete this service?')) return;
|
||||
try {
|
||||
await fetch(`${API_URL}/services/${id}`, { method: 'DELETE' });
|
||||
fetchServices();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete service:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold text-slate-800">Service Catalog</h1>
|
||||
<button onClick={() => setShowForm(true)}
|
||||
className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700">
|
||||
<Plus className="w-4 h-4" /> Add Service
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="text-center py-8">Loading...</div> : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{services.map(service => (
|
||||
<div key={service.id} className="bg-white rounded-xl border border-slate-200 p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center">
|
||||
<Scissors className="w-6 h-6 text-indigo-600" />
|
||||
</div>
|
||||
<button onClick={() => handleDelete(service.id)} className="text-slate-400 hover:text-red-500 text-sm">Delete</button>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-slate-800 mb-1">{service.name}</h3>
|
||||
<p className="text-sm text-slate-500 mb-4">{service.description || 'No description'}</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-1 text-slate-500"><Clock className="w-3 h-3" /> Duration</span>
|
||||
<span className="font-medium text-slate-700">{service.duration} min</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-1 text-slate-500"><DollarSign className="w-3 h-3" /> Price</span>
|
||||
<span className="font-bold text-indigo-600">${service.basePrice.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{services.length === 0 && (
|
||||
<div className="col-span-full text-center py-12 text-slate-400">
|
||||
No services yet. Add your first service to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold text-slate-800 mb-4">Add Service</h2>
|
||||
<div className="space-y-4">
|
||||
<input value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Service name" className="border rounded-lg px-3 py-2 w-full" />
|
||||
<textarea value={formData.description} onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Description" rows={2} className="border rounded-lg px-3 py-2 w-full" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<input value={formData.duration} onChange={e => setFormData({ ...formData, duration: Number(e.target.value) })}
|
||||
placeholder="Duration (min)" type="number" className="border rounded-lg px-3 py-2 w-full" />
|
||||
<input value={formData.basePrice} onChange={e => setFormData({ ...formData, basePrice: Number(e.target.value) })}
|
||||
placeholder="Base price ($)" type="number" step="0.01" className="border rounded-lg px-3 py-2 w-full" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setShowForm(false)} className="flex-1 px-4 py-2 border border-slate-300 rounded-lg">Cancel</button>
|
||||
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user