feat: appointment scheduling, client/pet/service/staff CRUD UI #15
@@ -0,0 +1,11 @@
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -15,7 +15,9 @@
|
||||
"@groombook/db": "workspace:*",
|
||||
"@groombook/types": "workspace:*",
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@hono/zod-validator": "^0.4.3",
|
||||
"hono": "^4.6.17",
|
||||
"jose": "^5.9.6",
|
||||
"openid-client": "^6.1.7",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { clientsRouter } from "./routes/clients.js";
|
||||
import { petsRouter } from "./routes/pets.js";
|
||||
import { servicesRouter } from "./routes/services.js";
|
||||
import { appointmentsRouter } from "./routes/appointments.js";
|
||||
import { staffRouter } from "./routes/staff.js";
|
||||
import { authMiddleware } from "./middleware/auth.js";
|
||||
|
||||
const app = new Hono();
|
||||
@@ -31,6 +32,7 @@ api.route("/clients", clientsRouter);
|
||||
api.route("/pets", petsRouter);
|
||||
api.route("/services", servicesRouter);
|
||||
api.route("/appointments", appointmentsRouter);
|
||||
api.route("/staff", staffRouter);
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
console.log(`API server listening on port ${port}`);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { and, eq, gte, lte } from "drizzle-orm";
|
||||
import { getDb, appointments } from "@groombook/db";
|
||||
import { and, eq, getDb, gte, lt, lte, ne, appointments } from "@groombook/db";
|
||||
|
||||
export const appointmentsRouter = new Hono();
|
||||
|
||||
@@ -35,15 +34,43 @@ const updateAppointmentSchema = z.object({
|
||||
priceCents: z.number().int().positive().nullable().optional(),
|
||||
});
|
||||
|
||||
// List appointments, optionally filtered by date range
|
||||
/** Returns true if a staff member has a non-cancelled appointment overlapping [start, end). */
|
||||
async function hasConflict(
|
||||
staffId: string,
|
||||
start: Date,
|
||||
end: Date,
|
||||
excludeId?: string
|
||||
): Promise<boolean> {
|
||||
const db = getDb();
|
||||
const conditions = [
|
||||
eq(appointments.staffId, staffId),
|
||||
// Overlap: existing.start < end AND existing.end > start
|
||||
lt(appointments.startTime, end),
|
||||
gte(appointments.endTime, start),
|
||||
// Ignore cancelled/no_show
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
];
|
||||
if (excludeId) conditions.push(ne(appointments.id, excludeId));
|
||||
const rows = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(and(...conditions))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// List appointments, optionally filtered by date range or staffId
|
||||
appointmentsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const from = c.req.query("from");
|
||||
const to = c.req.query("to");
|
||||
const staffId = c.req.query("staffId");
|
||||
|
||||
const conditions = [];
|
||||
if (from) conditions.push(gte(appointments.startTime, new Date(from)));
|
||||
if (to) conditions.push(lte(appointments.startTime, new Date(to)));
|
||||
if (staffId) conditions.push(eq(appointments.staffId, staffId));
|
||||
|
||||
const rows =
|
||||
conditions.length > 0
|
||||
@@ -76,13 +103,26 @@ appointmentsRouter.post(
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const start = new Date(body.startTime);
|
||||
const end = new Date(body.endTime);
|
||||
|
||||
if (end <= start) {
|
||||
return c.json({ error: "endTime must be after startTime" }, 422);
|
||||
}
|
||||
|
||||
if (body.staffId) {
|
||||
const conflict = await hasConflict(body.staffId, start, end);
|
||||
if (conflict) {
|
||||
return c.json(
|
||||
{ error: "Staff member has a conflicting appointment at this time" },
|
||||
409
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.insert(appointments)
|
||||
.values({
|
||||
...body,
|
||||
startTime: new Date(body.startTime),
|
||||
endTime: new Date(body.endTime),
|
||||
})
|
||||
.values({ ...body, startTime: start, endTime: end })
|
||||
.returning();
|
||||
return c.json(row, 201);
|
||||
}
|
||||
@@ -93,16 +133,57 @@ appointmentsRouter.patch(
|
||||
zValidator("json", updateAppointmentSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
// If rescheduling, check for conflicts
|
||||
if ((body.startTime || body.endTime || body.staffId !== undefined) && body.staffId) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
const current = existing[0];
|
||||
if (!current) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
const start = body.startTime ? new Date(body.startTime) : current.startTime;
|
||||
const end = body.endTime ? new Date(body.endTime) : current.endTime;
|
||||
const staffId = body.staffId ?? current.staffId;
|
||||
|
||||
if (end <= start) {
|
||||
return c.json({ error: "endTime must be after startTime" }, 422);
|
||||
}
|
||||
|
||||
if (staffId) {
|
||||
const conflict = await hasConflict(staffId, start, end, id);
|
||||
if (conflict) {
|
||||
return c.json(
|
||||
{ error: "Staff member has a conflicting appointment at this time" },
|
||||
409
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const update: Record<string, unknown> = { ...body, updatedAt: new Date() };
|
||||
if (body.startTime) update.startTime = new Date(body.startTime);
|
||||
if (body.endTime) update.endTime = new Date(body.endTime);
|
||||
const [row] = await db
|
||||
.update(appointments)
|
||||
.set(update)
|
||||
.where(eq(appointments.id, c.req.param("id")))
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
);
|
||||
|
||||
appointmentsRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.delete(appointments)
|
||||
.where(eq(appointments.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDb, clients } from "@groombook/db";
|
||||
import { eq, getDb, clients } from "@groombook/db";
|
||||
|
||||
export const clientsRouter = new Hono();
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDb, pets } from "@groombook/db";
|
||||
import { eq, getDb, pets } from "@groombook/db";
|
||||
|
||||
export const petsRouter = new Hono();
|
||||
|
||||
@@ -42,8 +41,15 @@ petsRouter.get("/:id", async (c) => {
|
||||
|
||||
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const [row] = await db.insert(pets).values(body).returning();
|
||||
const { weightKg, dateOfBirth, ...rest } = c.req.valid("json");
|
||||
const [row] = await db
|
||||
.insert(pets)
|
||||
.values({
|
||||
...rest,
|
||||
weightKg: weightKg?.toString(),
|
||||
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
|
||||
})
|
||||
.returning();
|
||||
return c.json(row, 201);
|
||||
});
|
||||
|
||||
@@ -52,10 +58,15 @@ petsRouter.patch(
|
||||
zValidator("json", updatePetSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const { weightKg, dateOfBirth, ...rest } = c.req.valid("json");
|
||||
const [row] = await db
|
||||
.update(pets)
|
||||
.set({ ...body, updatedAt: new Date() })
|
||||
.set({
|
||||
...rest,
|
||||
weightKg: weightKg?.toString(),
|
||||
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pets.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDb, services } from "@groombook/db";
|
||||
import { eq, getDb, services } from "@groombook/db";
|
||||
|
||||
export const servicesRouter = new Hono();
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { eq, getDb, staff } from "@groombook/db";
|
||||
|
||||
export const staffRouter = new Hono();
|
||||
|
||||
const createStaffSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
email: z.string().email(),
|
||||
role: z.enum(["groomer", "receptionist", "manager"]).default("groomer"),
|
||||
oidcSub: z.string().optional(),
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
const updateStaffSchema = createStaffSchema.partial().omit({ email: true });
|
||||
|
||||
staffRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const includeInactive = c.req.query("includeInactive") === "true";
|
||||
const rows = includeInactive
|
||||
? await db.select().from(staff).orderBy(staff.name)
|
||||
: await db.select().from(staff).where(eq(staff.active, true)).orderBy(staff.name);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
staffRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.id, c.req.param("id")));
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
staffRouter.post("/", zValidator("json", createStaffSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const [row] = await db.insert(staff).values(body).returning();
|
||||
return c.json(row, 201);
|
||||
});
|
||||
|
||||
staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const [row] = await db
|
||||
.update(staff)
|
||||
.set({ ...body, updatedAt: new Date() })
|
||||
.where(eq(staff.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
staffRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.delete(staff)
|
||||
.where(eq(staff.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
},
|
||||
}
|
||||
);
|
||||
+45
-12
@@ -1,26 +1,59 @@
|
||||
import { Routes, Route, Link } from "react-router-dom";
|
||||
import { Routes, Route, Link, useLocation } from "react-router-dom";
|
||||
import { AppointmentsPage } from "./pages/Appointments.js";
|
||||
import { ClientsPage } from "./pages/Clients.js";
|
||||
import { ServicesPage } from "./pages/Services.js";
|
||||
import { StaffPage } from "./pages/Staff.js";
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ to: "/", label: "Appointments" },
|
||||
{ to: "/clients", label: "Clients" },
|
||||
{ to: "/services", label: "Services" },
|
||||
{ to: "/staff", label: "Staff" },
|
||||
];
|
||||
|
||||
export function App() {
|
||||
const location = useLocation();
|
||||
return (
|
||||
<div>
|
||||
<nav style={{ padding: "1rem", borderBottom: "1px solid #e2e8f0" }}>
|
||||
<strong style={{ marginRight: "1.5rem" }}>Groom Book</strong>
|
||||
<Link to="/" style={{ marginRight: "1rem" }}>
|
||||
Appointments
|
||||
</Link>
|
||||
<Link to="/clients" style={{ marginRight: "1rem" }}>
|
||||
Clients
|
||||
</Link>
|
||||
<Link to="/services">Services</Link>
|
||||
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif" }}>
|
||||
<nav
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
borderBottom: "1px solid #e2e8f0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<strong style={{ marginRight: "1rem", fontSize: 16 }}>Groom Book</strong>
|
||||
{NAV_LINKS.map(({ to, label }) => {
|
||||
const active =
|
||||
to === "/" ? location.pathname === "/" : location.pathname.startsWith(to);
|
||||
return (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
style={{
|
||||
padding: "0.35rem 0.75rem",
|
||||
borderRadius: 4,
|
||||
textDecoration: "none",
|
||||
fontSize: 14,
|
||||
fontWeight: active ? 600 : 400,
|
||||
color: active ? "#1d4ed8" : "#374151",
|
||||
background: active ? "#eff6ff" : "transparent",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<main style={{ padding: "1rem" }}>
|
||||
<main style={{ padding: "1rem 1.5rem" }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppointmentsPage />} />
|
||||
<Route path="/clients" element={<ClientsPage />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route path="/staff" element={<StaffPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,41 +1,563 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Appointment } from "@groombook/types";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types";
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function startOfWeek(date: Date): Date {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay(); // 0=Sun
|
||||
const diff = day === 0 ? -6 : 1 - day; // Monday start
|
||||
d.setDate(d.getDate() + diff);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function addDays(date: Date, n: number): Date {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + n);
|
||||
return d;
|
||||
}
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function fmtDateShort(d: Date): string {
|
||||
return d.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
scheduled: "#3b82f6",
|
||||
confirmed: "#10b981",
|
||||
in_progress: "#f59e0b",
|
||||
completed: "#6b7280",
|
||||
cancelled: "#ef4444",
|
||||
no_show: "#9ca3af",
|
||||
};
|
||||
|
||||
const STATUS_TRANSITIONS: Record<string, string[]> = {
|
||||
scheduled: ["confirmed", "cancelled", "no_show"],
|
||||
confirmed: ["in_progress", "cancelled", "no_show"],
|
||||
in_progress: ["completed", "no_show"],
|
||||
completed: [],
|
||||
cancelled: [],
|
||||
no_show: [],
|
||||
};
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BookingForm {
|
||||
clientId: string;
|
||||
petId: string;
|
||||
serviceId: string;
|
||||
staffId: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: BookingForm = {
|
||||
clientId: "",
|
||||
petId: "",
|
||||
serviceId: "",
|
||||
staffId: "",
|
||||
date: formatDate(new Date()),
|
||||
startTime: "09:00",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function AppointmentsPage() {
|
||||
const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date()));
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [pets, setPets] = useState<Pet[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [staff, setStaff] = useState<Staff[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState<BookingForm>(EMPTY_FORM);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [selectedAppt, setSelectedAppt] = useState<Appointment | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/appointments")
|
||||
const weekEnd = addDays(weekStart, 6);
|
||||
|
||||
const loadAppointments = useCallback(() => {
|
||||
const from = weekStart.toISOString();
|
||||
const to = addDays(weekStart, 7).toISOString();
|
||||
return fetch(`/api/appointments?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json() as Promise<Appointment[]>;
|
||||
})
|
||||
.then(setAppointments)
|
||||
.catch((e: unknown) =>
|
||||
setError(e instanceof Error ? e.message : "Unknown error")
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
.then(setAppointments);
|
||||
}, [weekStart]);
|
||||
|
||||
if (loading) return <p>Loading appointments…</p>;
|
||||
if (error) return <p style={{ color: "red" }}>Error: {error}</p>;
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
Promise.all([
|
||||
loadAppointments(),
|
||||
fetch("/api/clients").then((r) => r.json() as Promise<Client[]>).then(setClients),
|
||||
fetch("/api/services").then((r) => r.json() as Promise<Service[]>).then(setServices),
|
||||
fetch("/api/staff").then((r) => r.json() as Promise<Staff[]>).then(setStaff),
|
||||
])
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [loadAppointments]);
|
||||
|
||||
// Load pets when client is selected
|
||||
useEffect(() => {
|
||||
if (!form.clientId) {
|
||||
setPets([]);
|
||||
setForm((f) => ({ ...f, petId: "" }));
|
||||
return;
|
||||
}
|
||||
fetch(`/api/pets?clientId=${encodeURIComponent(form.clientId)}`)
|
||||
.then((r) => r.json() as Promise<Pet[]>)
|
||||
.then(setPets);
|
||||
}, [form.clientId]);
|
||||
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
|
||||
|
||||
const apptsByDay = days.map((day) => {
|
||||
const dateStr = formatDate(day);
|
||||
return appointments.filter((a) => a.startTime.startsWith(dateStr));
|
||||
});
|
||||
|
||||
function openNewForm(date?: Date) {
|
||||
setForm({ ...EMPTY_FORM, date: formatDate(date ?? new Date()) });
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
async function submitBooking(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.clientId || !form.petId || !form.serviceId) {
|
||||
setFormError("Client, pet, and service are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const service = services.find((s) => s.id === form.serviceId);
|
||||
if (!service) return;
|
||||
|
||||
const startISO = new Date(`${form.date}T${form.startTime}`).toISOString();
|
||||
const endDate = new Date(`${form.date}T${form.startTime}`);
|
||||
endDate.setMinutes(endDate.getMinutes() + service.durationMinutes);
|
||||
const endISO = endDate.toISOString();
|
||||
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const res = await fetch("/api/appointments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId: form.clientId,
|
||||
petId: form.petId,
|
||||
serviceId: form.serviceId,
|
||||
staffId: form.staffId || undefined,
|
||||
startTime: startISO,
|
||||
endTime: endISO,
|
||||
notes: form.notes || undefined,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
setShowForm(false);
|
||||
await loadAppointments();
|
||||
} catch (e: unknown) {
|
||||
setFormError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatus(appt: Appointment, status: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/appointments/${appt.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setSelectedAppt(null);
|
||||
await loadAppointments();
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : "Failed to update");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAppt(id: string) {
|
||||
if (!confirm("Delete this appointment?")) return;
|
||||
await fetch(`/api/appointments/${id}`, { method: "DELETE" });
|
||||
setSelectedAppt(null);
|
||||
await loadAppointments();
|
||||
}
|
||||
|
||||
if (loading) return <p style={{ padding: "1rem" }}>Loading…</p>;
|
||||
if (error) return <p style={{ padding: "1rem", color: "red" }}>Error: {error}</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Appointments</h1>
|
||||
{appointments.length === 0 ? (
|
||||
<p>No appointments yet.</p>
|
||||
) : (
|
||||
<ul>
|
||||
{appointments.map((a) => (
|
||||
<li key={a.id}>
|
||||
{new Date(a.startTime).toLocaleString()} — {a.status}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ fontFamily: "system-ui, sans-serif" }}>
|
||||
{/* ── Header ── */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "1rem", flexWrap: "wrap" }}>
|
||||
<h1 style={{ margin: 0 }}>Appointments</h1>
|
||||
<button onClick={() => setWeekStart((w) => addDays(w, -7))} style={btnStyle}>
|
||||
← Prev
|
||||
</button>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>
|
||||
{fmtDateShort(weekStart)} – {fmtDateShort(weekEnd)}
|
||||
</span>
|
||||
<button onClick={() => setWeekStart((w) => addDays(w, 7))} style={btnStyle}>
|
||||
Next →
|
||||
</button>
|
||||
<button onClick={() => setWeekStart(startOfWeek(new Date()))} style={btnStyle}>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openNewForm()}
|
||||
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", marginLeft: "auto", borderColor: "#3b82f6" }}
|
||||
>
|
||||
+ New Appointment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Weekly Calendar ── */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: "0.5rem" }}>
|
||||
{days.map((day, i) => {
|
||||
const isToday = formatDate(day) === formatDate(new Date());
|
||||
return (
|
||||
<div key={i} style={{ border: "1px solid #e2e8f0", borderRadius: 6, overflow: "hidden", minHeight: 180 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "0.35rem 0.5rem",
|
||||
background: isToday ? "#3b82f6" : "#f8fafc",
|
||||
color: isToday ? "#fff" : "#374151",
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span>{fmtDateShort(day)}</span>
|
||||
<button
|
||||
onClick={() => openNewForm(day)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: isToday ? "#fff" : "#6b7280",
|
||||
cursor: "pointer",
|
||||
fontSize: 16,
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
title="Add appointment"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: "0.3rem" }}>
|
||||
{(apptsByDay[i] ?? []).map((a) => {
|
||||
const svc = services.find((s) => s.id === a.serviceId);
|
||||
const cli = clients.find((c) => c.id === a.clientId);
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
onClick={() => setSelectedAppt(a)}
|
||||
style={{
|
||||
background: STATUS_COLORS[a.status] ?? "#94a3b8",
|
||||
color: "#fff",
|
||||
borderRadius: 4,
|
||||
padding: "0.2rem 0.35rem",
|
||||
marginBottom: "0.2rem",
|
||||
fontSize: 11,
|
||||
cursor: "pointer",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{fmtTime(a.startTime)}</div>
|
||||
<div>{cli?.name ?? "—"}</div>
|
||||
<div style={{ opacity: 0.9 }}>{svc?.name ?? "—"}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Booking Form Modal ── */}
|
||||
{showForm && (
|
||||
<Modal onClose={() => setShowForm(false)}>
|
||||
<h2 style={{ marginTop: 0 }}>New Appointment</h2>
|
||||
<form onSubmit={submitBooking}>
|
||||
<Field label="Client">
|
||||
<select
|
||||
value={form.clientId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, clientId: e.target.value }))}
|
||||
required
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">— select client —</option>
|
||||
{clients.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Pet">
|
||||
<select
|
||||
value={form.petId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, petId: e.target.value }))}
|
||||
required
|
||||
disabled={!form.clientId}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">— select pet —</option>
|
||||
{pets.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Service">
|
||||
<select
|
||||
value={form.serviceId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, serviceId: e.target.value }))}
|
||||
required
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">— select service —</option>
|
||||
{services.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} ({s.durationMinutes} min — ${(s.basePriceCents / 100).toFixed(2)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Groomer (optional)">
|
||||
<select
|
||||
value={form.staffId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, staffId: e.target.value }))}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">— any / unassigned —</option>
|
||||
{staff.filter((s) => s.active).map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Date">
|
||||
<input
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))}
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Start time">
|
||||
<input
|
||||
type="time"
|
||||
value={form.startTime}
|
||||
onChange={(e) => setForm((f) => ({ ...f, startTime: e.target.value }))}
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Notes">
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
rows={3}
|
||||
style={{ ...inputStyle, resize: "vertical" }}
|
||||
/>
|
||||
</Field>
|
||||
{formError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{formError}</p>}
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}
|
||||
>
|
||||
{saving ? "Saving…" : "Book Appointment"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowForm(false)} style={btnStyle}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* ── Appointment Detail Modal ── */}
|
||||
{selectedAppt && (
|
||||
<Modal onClose={() => setSelectedAppt(null)}>
|
||||
<AppointmentDetail
|
||||
appt={selectedAppt}
|
||||
clients={clients}
|
||||
services={services}
|
||||
staff={staff}
|
||||
onUpdateStatus={updateStatus}
|
||||
onDelete={deleteAppt}
|
||||
onClose={() => setSelectedAppt(null)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
function AppointmentDetail({
|
||||
appt,
|
||||
clients,
|
||||
services,
|
||||
staff,
|
||||
onUpdateStatus,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: {
|
||||
appt: Appointment;
|
||||
clients: Client[];
|
||||
services: Service[];
|
||||
staff: Staff[];
|
||||
onUpdateStatus: (a: Appointment, status: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const client = clients.find((c) => c.id === appt.clientId);
|
||||
const service = services.find((s) => s.id === appt.serviceId);
|
||||
const groomer = staff.find((s) => s.id === appt.staffId);
|
||||
const transitions = STATUS_TRANSITIONS[appt.status] ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 style={{ marginTop: 0 }}>Appointment Details</h2>
|
||||
<table style={{ borderCollapse: "collapse", width: "100%", marginBottom: "1rem", fontSize: 14 }}>
|
||||
<tbody>
|
||||
{([
|
||||
["Client", client?.name ?? "—"],
|
||||
["Service", service?.name ?? "—"],
|
||||
["Groomer", groomer?.name ?? "Unassigned"],
|
||||
["Start", new Date(appt.startTime).toLocaleString()],
|
||||
["End", new Date(appt.endTime).toLocaleString()],
|
||||
["Status", appt.status.replace("_", " ")],
|
||||
["Notes", appt.notes ?? "—"],
|
||||
] as [string, string][]).map(([label, value]) => (
|
||||
<tr key={label}>
|
||||
<td style={{ padding: "4px 12px 4px 0", fontWeight: 600, whiteSpace: "nowrap", verticalAlign: "top", color: "#6b7280" }}>
|
||||
{label}
|
||||
</td>
|
||||
<td style={{ padding: "4px 0" }}>{value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{transitions.length > 0 && (
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13, marginRight: "0.5rem" }}>Move to:</span>
|
||||
{transitions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onUpdateStatus(appt, s)}
|
||||
style={{
|
||||
...btnStyle,
|
||||
backgroundColor: STATUS_COLORS[s],
|
||||
color: "#fff",
|
||||
borderColor: STATUS_COLORS[s],
|
||||
marginRight: "0.4rem",
|
||||
marginBottom: "0.3rem",
|
||||
}}
|
||||
>
|
||||
{s.replace("_", " ")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{appt.status !== "completed" && appt.status !== "cancelled" && (
|
||||
<button
|
||||
onClick={() => onDelete(appt.id)}
|
||||
style={{ ...btnStyle, backgroundColor: "#ef4444", color: "#fff", borderColor: "#ef4444" }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} style={btnStyle}>Close</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 100,
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 8,
|
||||
padding: "1.5rem",
|
||||
maxWidth: 500,
|
||||
width: "calc(100% - 2rem)",
|
||||
maxHeight: "90vh",
|
||||
overflowY: "auto",
|
||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13, color: "#374151" }}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: "0.35rem 0.75rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 4,
|
||||
background: "#f9fafb",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "0.4rem 0.5rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
+363
-23
@@ -1,41 +1,381 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Client } from "@groombook/types";
|
||||
import type { Client, Pet } from "@groombook/types";
|
||||
|
||||
// ─── Forms ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ClientForm {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface PetForm {
|
||||
name: string;
|
||||
species: string;
|
||||
breed: string;
|
||||
weightStr: string;
|
||||
dob: string;
|
||||
groomingNotes: string;
|
||||
}
|
||||
|
||||
const EMPTY_CLIENT: ClientForm = { name: "", email: "", phone: "", address: "", notes: "" };
|
||||
const EMPTY_PET: PetForm = { name: "", species: "Dog", breed: "", weightStr: "", dob: "", groomingNotes: "" };
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function ClientsPage() {
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedClient, setSelectedClient] = useState<Client | null>(null);
|
||||
const [pets, setPets] = useState<Pet[]>([]);
|
||||
const [petsLoading, setPetsLoading] = useState(false);
|
||||
|
||||
// Client form
|
||||
const [showClientForm, setShowClientForm] = useState(false);
|
||||
const [editingClient, setEditingClient] = useState<Client | null>(null);
|
||||
const [clientForm, setClientForm] = useState<ClientForm>(EMPTY_CLIENT);
|
||||
const [clientFormError, setClientFormError] = useState<string | null>(null);
|
||||
const [savingClient, setSavingClient] = useState(false);
|
||||
|
||||
// Pet form
|
||||
const [showPetForm, setShowPetForm] = useState(false);
|
||||
const [editingPet, setEditingPet] = useState<Pet | null>(null);
|
||||
const [petForm, setPetForm] = useState<PetForm>(EMPTY_PET);
|
||||
const [petFormError, setPetFormError] = useState<string | null>(null);
|
||||
const [savingPet, setSavingPet] = useState(false);
|
||||
|
||||
async function loadClients() {
|
||||
const r = await fetch("/api/clients");
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
setClients((await r.json()) as Client[]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/clients")
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json() as Promise<Client[]>;
|
||||
})
|
||||
.then(setClients)
|
||||
.catch((e: unknown) =>
|
||||
setError(e instanceof Error ? e.message : "Unknown error")
|
||||
)
|
||||
loadClients()
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <p>Loading clients…</p>;
|
||||
if (error) return <p style={{ color: "red" }}>Error: {error}</p>;
|
||||
async function loadPets(clientId: string) {
|
||||
setPetsLoading(true);
|
||||
const r = await fetch(`/api/pets?clientId=${encodeURIComponent(clientId)}`);
|
||||
setPets((await r.json()) as Pet[]);
|
||||
setPetsLoading(false);
|
||||
}
|
||||
|
||||
function selectClient(c: Client) {
|
||||
setSelectedClient(c);
|
||||
loadPets(c.id);
|
||||
}
|
||||
|
||||
// ── Client CRUD ──
|
||||
|
||||
function openNewClient() {
|
||||
setEditingClient(null);
|
||||
setClientForm(EMPTY_CLIENT);
|
||||
setClientFormError(null);
|
||||
setShowClientForm(true);
|
||||
}
|
||||
|
||||
function openEditClient(c: Client) {
|
||||
setEditingClient(c);
|
||||
setClientForm({ name: c.name, email: c.email ?? "", phone: c.phone ?? "", address: c.address ?? "", notes: c.notes ?? "" });
|
||||
setClientFormError(null);
|
||||
setShowClientForm(true);
|
||||
}
|
||||
|
||||
async function submitClient(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSavingClient(true);
|
||||
setClientFormError(null);
|
||||
try {
|
||||
const body = {
|
||||
name: clientForm.name,
|
||||
email: clientForm.email || undefined,
|
||||
phone: clientForm.phone || undefined,
|
||||
address: clientForm.address || undefined,
|
||||
notes: clientForm.notes || undefined,
|
||||
};
|
||||
const res = editingClient
|
||||
? await fetch(`/api/clients/${editingClient.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
|
||||
: await fetch("/api/clients", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const updated = (await res.json()) as Client;
|
||||
setShowClientForm(false);
|
||||
await loadClients();
|
||||
if (editingClient) setSelectedClient(updated);
|
||||
} catch (e: unknown) {
|
||||
setClientFormError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSavingClient(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet CRUD ──
|
||||
|
||||
function openNewPet() {
|
||||
setEditingPet(null);
|
||||
setPetForm(EMPTY_PET);
|
||||
setPetFormError(null);
|
||||
setShowPetForm(true);
|
||||
}
|
||||
|
||||
function openEditPet(p: Pet) {
|
||||
setEditingPet(p);
|
||||
setPetForm({
|
||||
name: p.name, species: p.species, breed: p.breed ?? "",
|
||||
weightStr: p.weightKg != null ? String(p.weightKg) : "",
|
||||
dob: p.dateOfBirth ? p.dateOfBirth.slice(0, 10) : "",
|
||||
groomingNotes: p.groomingNotes ?? "",
|
||||
});
|
||||
setPetFormError(null);
|
||||
setShowPetForm(true);
|
||||
}
|
||||
|
||||
async function submitPet(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!selectedClient) return;
|
||||
setSavingPet(true);
|
||||
setPetFormError(null);
|
||||
try {
|
||||
const body = {
|
||||
clientId: selectedClient.id,
|
||||
name: petForm.name,
|
||||
species: petForm.species,
|
||||
breed: petForm.breed || undefined,
|
||||
weightKg: petForm.weightStr ? parseFloat(petForm.weightStr) : undefined,
|
||||
dateOfBirth: petForm.dob ? new Date(petForm.dob).toISOString() : undefined,
|
||||
groomingNotes: petForm.groomingNotes || undefined,
|
||||
};
|
||||
const res = editingPet
|
||||
? await fetch(`/api/pets/${editingPet.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
|
||||
: await fetch("/api/pets", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
setShowPetForm(false);
|
||||
await loadPets(selectedClient.id);
|
||||
} catch (e: unknown) {
|
||||
setPetFormError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSavingPet(false);
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = search
|
||||
? clients.filter((c) =>
|
||||
c.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.email?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.phone?.includes(search)
|
||||
)
|
||||
: clients;
|
||||
|
||||
if (loading) return <p style={{ padding: "1rem" }}>Loading…</p>;
|
||||
if (error) return <p style={{ padding: "1rem", color: "red" }}>Error: {error}</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Clients</h1>
|
||||
{clients.length === 0 ? (
|
||||
<p>No clients yet.</p>
|
||||
<div style={{ fontFamily: "system-ui, sans-serif", display: "flex", gap: "1.5rem" }}>
|
||||
{/* ── Client list ── */}
|
||||
<div style={{ width: 280, flexShrink: 0, borderRight: "1px solid #e2e8f0", paddingRight: "1rem" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", marginBottom: "0.75rem" }}>
|
||||
<h1 style={{ margin: 0, fontSize: 20 }}>Clients</h1>
|
||||
<button
|
||||
onClick={openNewClient}
|
||||
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6", marginLeft: "auto", padding: "0.25rem 0.6rem" }}
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
placeholder="Search…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ ...inputStyle, marginBottom: "0.75rem" }}
|
||||
/>
|
||||
{filtered.length === 0 && <p style={{ color: "#6b7280", fontSize: 14 }}>No clients found.</p>}
|
||||
{filtered.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
onClick={() => selectClient(c)}
|
||||
style={{
|
||||
padding: "0.5rem 0.6rem", borderRadius: 6, cursor: "pointer", marginBottom: "0.2rem",
|
||||
background: selectedClient?.id === c.id ? "#eff6ff" : "transparent",
|
||||
border: selectedClient?.id === c.id ? "1px solid #bfdbfe" : "1px solid transparent",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{c.name}</div>
|
||||
{c.email && <div style={{ fontSize: 12, color: "#6b7280" }}>{c.email}</div>}
|
||||
{c.phone && <div style={{ fontSize: 12, color: "#6b7280" }}>{c.phone}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Client detail ── */}
|
||||
{selectedClient ? (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", marginBottom: "1rem" }}>
|
||||
<div>
|
||||
<h2 style={{ margin: "0 0 0.2rem" }}>{selectedClient.name}</h2>
|
||||
{selectedClient.email && <div style={{ fontSize: 14, color: "#6b7280" }}>{selectedClient.email}</div>}
|
||||
{selectedClient.phone && <div style={{ fontSize: 14, color: "#6b7280" }}>{selectedClient.phone}</div>}
|
||||
{selectedClient.address && <div style={{ fontSize: 13, color: "#6b7280" }}>{selectedClient.address}</div>}
|
||||
{selectedClient.notes && (
|
||||
<div style={{ fontSize: 13, marginTop: "0.4rem", background: "#fef9c3", padding: "0.4rem 0.6rem", borderRadius: 4, maxWidth: 500 }}>
|
||||
{selectedClient.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => openEditClient(selectedClient)} style={{ ...btnStyle, marginLeft: "auto" }}>
|
||||
Edit client
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.75rem" }}>
|
||||
<h3 style={{ margin: 0 }}>Pets</h3>
|
||||
<button onClick={openNewPet} style={{ ...btnStyle, backgroundColor: "#10b981", color: "#fff", borderColor: "#10b981" }}>
|
||||
+ Add pet
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{petsLoading ? (
|
||||
<p style={{ fontSize: 14 }}>Loading pets…</p>
|
||||
) : pets.length === 0 ? (
|
||||
<p style={{ color: "#6b7280", fontSize: 14 }}>No pets on file for this client.</p>
|
||||
) : (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: "0.75rem" }}>
|
||||
{pets.map((p) => (
|
||||
<div key={p.id} style={{ border: "1px solid #e2e8f0", borderRadius: 8, padding: "0.75rem" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<strong style={{ fontSize: 15 }}>{p.name}</strong>
|
||||
<button onClick={() => openEditPet(p)} style={{ ...btnStyle, padding: "0.15rem 0.5rem", fontSize: 11 }}>Edit</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.2rem" }}>
|
||||
{p.species}{p.breed ? ` · ${p.breed}` : ""}
|
||||
</div>
|
||||
{p.weightKg != null && <div style={{ fontSize: 12, color: "#6b7280" }}>{p.weightKg} kg</div>}
|
||||
{p.dateOfBirth && <div style={{ fontSize: 12, color: "#6b7280" }}>Born {new Date(p.dateOfBirth).toLocaleDateString()}</div>}
|
||||
{p.groomingNotes && (
|
||||
<div style={{ fontSize: 12, marginTop: "0.35rem", color: "#374151" }}>
|
||||
<span style={{ fontWeight: 600 }}>Notes:</span> {p.groomingNotes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
{clients.map((c) => (
|
||||
<li key={c.id}>
|
||||
{c.name} {c.email ? `— ${c.email}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#9ca3af", fontSize: 15 }}>
|
||||
Select a client to view details
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Client modal ── */}
|
||||
{showClientForm && (
|
||||
<Modal onClose={() => setShowClientForm(false)}>
|
||||
<h2 style={{ marginTop: 0 }}>{editingClient ? "Edit Client" : "New Client"}</h2>
|
||||
<form onSubmit={submitClient}>
|
||||
<Field label="Full name">
|
||||
<input value={clientForm.name} onChange={(e) => setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Email">
|
||||
<input type="email" value={clientForm.email} onChange={(e) => setClientForm((f) => ({ ...f, email: e.target.value }))} style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Phone">
|
||||
<input value={clientForm.phone} onChange={(e) => setClientForm((f) => ({ ...f, phone: e.target.value }))} style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Address">
|
||||
<input value={clientForm.address} onChange={(e) => setClientForm((f) => ({ ...f, address: e.target.value }))} style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Notes">
|
||||
<textarea value={clientForm.notes} onChange={(e) => setClientForm((f) => ({ ...f, notes: e.target.value }))} rows={3} style={{ ...inputStyle, resize: "vertical" }} />
|
||||
</Field>
|
||||
{clientFormError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{clientFormError}</p>}
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
|
||||
<button type="submit" disabled={savingClient} style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}>
|
||||
{savingClient ? "Saving…" : editingClient ? "Save Changes" : "Create Client"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowClientForm(false)} style={btnStyle}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* ── Pet modal ── */}
|
||||
{showPetForm && (
|
||||
<Modal onClose={() => setShowPetForm(false)}>
|
||||
<h2 style={{ marginTop: 0 }}>{editingPet ? "Edit Pet" : "Add Pet"}</h2>
|
||||
<form onSubmit={submitPet}>
|
||||
<Field label="Pet name">
|
||||
<input value={petForm.name} onChange={(e) => setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Species">
|
||||
<select value={petForm.species} onChange={(e) => setPetForm((f) => ({ ...f, species: e.target.value }))} style={inputStyle}>
|
||||
{["Dog", "Cat", "Rabbit", "Guinea Pig", "Other"].map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Breed (optional)">
|
||||
<input value={petForm.breed} onChange={(e) => setPetForm((f) => ({ ...f, breed: e.target.value }))} style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Weight kg (optional)">
|
||||
<input type="number" step="0.1" min="0" value={petForm.weightStr} onChange={(e) => setPetForm((f) => ({ ...f, weightStr: e.target.value }))} style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Date of birth (optional)">
|
||||
<input type="date" value={petForm.dob} onChange={(e) => setPetForm((f) => ({ ...f, dob: e.target.value }))} style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Grooming notes (optional)">
|
||||
<textarea value={petForm.groomingNotes} onChange={(e) => setPetForm((f) => ({ ...f, groomingNotes: e.target.value }))} rows={2} style={{ ...inputStyle, resize: "vertical" }} />
|
||||
</Field>
|
||||
{petFormError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{petFormError}</p>}
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
|
||||
<button type="submit" disabled={savingPet} style={{ ...btnStyle, backgroundColor: "#10b981", color: "#fff", borderColor: "#10b981" }}>
|
||||
{savingPet ? "Saving…" : editingPet ? "Save Changes" : "Add Pet"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowPetForm(false)} style={btnStyle}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared UI ───────────────────────────────────────────────────────────────
|
||||
|
||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13, color: "#374151" }}>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", borderRadius: 4, fontSize: 14, boxSizing: "border-box",
|
||||
};
|
||||
|
||||
+270
-21
@@ -1,42 +1,291 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Service } from "@groombook/types";
|
||||
|
||||
interface ServiceForm {
|
||||
name: string;
|
||||
description: string;
|
||||
priceStr: string;
|
||||
durationMinutes: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: ServiceForm = {
|
||||
name: "",
|
||||
description: "",
|
||||
priceStr: "",
|
||||
durationMinutes: 60,
|
||||
active: true,
|
||||
};
|
||||
|
||||
export function ServicesPage() {
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<Service | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState<ServiceForm>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
const r = await fetch("/api/services?includeInactive=true");
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data = (await r.json()) as Service[];
|
||||
setServices(data);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/services")
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json() as Promise<Service[]>;
|
||||
})
|
||||
.then(setServices)
|
||||
.catch((e: unknown) =>
|
||||
setError(e instanceof Error ? e.message : "Unknown error")
|
||||
)
|
||||
load()
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <p>Loading services…</p>;
|
||||
if (error) return <p style={{ color: "red" }}>Error: {error}</p>;
|
||||
function openNew() {
|
||||
setEditing(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
function openEdit(s: Service) {
|
||||
setEditing(s);
|
||||
setForm({
|
||||
name: s.name,
|
||||
description: s.description ?? "",
|
||||
priceStr: (s.basePriceCents / 100).toFixed(2),
|
||||
durationMinutes: s.durationMinutes,
|
||||
active: s.active,
|
||||
});
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const price = parseFloat(form.priceStr);
|
||||
if (isNaN(price) || price <= 0) {
|
||||
setFormError("Price must be a positive number.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const body = {
|
||||
name: form.name,
|
||||
description: form.description || undefined,
|
||||
basePriceCents: Math.round(price * 100),
|
||||
durationMinutes: form.durationMinutes,
|
||||
active: form.active,
|
||||
};
|
||||
const res = editing
|
||||
? await fetch(`/api/services/${editing.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
: await fetch("/api/services", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
setShowForm(false);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
setFormError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(s: Service) {
|
||||
await fetch(`/api/services/${s.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ active: !s.active }),
|
||||
});
|
||||
await load();
|
||||
}
|
||||
|
||||
if (loading) return <p style={{ padding: "1rem" }}>Loading…</p>;
|
||||
if (error) return <p style={{ padding: "1rem", color: "red" }}>Error: {error}</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Services</h1>
|
||||
<div style={{ fontFamily: "system-ui, sans-serif" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1rem" }}>
|
||||
<h1 style={{ margin: 0 }}>Services</h1>
|
||||
<button
|
||||
onClick={openNew}
|
||||
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6", marginLeft: "auto" }}
|
||||
>
|
||||
+ Add Service
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{services.length === 0 ? (
|
||||
<p>No services configured yet.</p>
|
||||
) : (
|
||||
<ul>
|
||||
{services.map((s) => (
|
||||
<li key={s.id}>
|
||||
{s.name} — ${(s.basePriceCents / 100).toFixed(2)} /{" "}
|
||||
{s.durationMinutes} min
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr style={{ background: "#f8fafc" }}>
|
||||
{["Name", "Description", "Price", "Duration", "Status", ""].map((h) => (
|
||||
<th key={h} style={{ textAlign: "left", padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0" }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{services.map((s) => (
|
||||
<tr key={s.id} style={{ opacity: s.active ? 1 : 0.5 }}>
|
||||
<td style={tdStyle}>{s.name}</td>
|
||||
<td style={tdStyle}>{s.description ?? "—"}</td>
|
||||
<td style={tdStyle}>${(s.basePriceCents / 100).toFixed(2)}</td>
|
||||
<td style={tdStyle}>{s.durationMinutes} min</td>
|
||||
<td style={tdStyle}>
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: 12,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
background: s.active ? "#d1fae5" : "#f3f4f6",
|
||||
color: s.active ? "#065f46" : "#6b7280",
|
||||
}}
|
||||
>
|
||||
{s.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ ...tdStyle, whiteSpace: "nowrap" }}>
|
||||
<button onClick={() => openEdit(s)} style={{ ...btnStyle, marginRight: "0.4rem" }}>
|
||||
Edit
|
||||
</button>
|
||||
<button onClick={() => toggleActive(s)} style={btnStyle}>
|
||||
{s.active ? "Deactivate" : "Activate"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<Modal onClose={() => setShowForm(false)}>
|
||||
<h2 style={{ marginTop: 0 }}>{editing ? "Edit Service" : "New Service"}</h2>
|
||||
<form onSubmit={submit}>
|
||||
<Field label="Name">
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Description (optional)">
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
rows={2}
|
||||
style={{ ...inputStyle, resize: "vertical" }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Price ($)">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
value={form.priceStr}
|
||||
onChange={(e) => setForm((f) => ({ ...f, priceStr: e.target.value }))}
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Duration (minutes)">
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
step="5"
|
||||
value={form.durationMinutes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, durationMinutes: parseInt(e.target.value) || 60 }))}
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Status">
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.active}
|
||||
onChange={(e) => setForm((f) => ({ ...f, active: e.target.checked }))}
|
||||
/>
|
||||
Active (visible to booking form)
|
||||
</label>
|
||||
</Field>
|
||||
{formError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{formError}</p>}
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}
|
||||
>
|
||||
{saving ? "Saving…" : editing ? "Save Changes" : "Create Service"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowForm(false)} style={btnStyle}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100,
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div style={{
|
||||
background: "#fff", borderRadius: 8, padding: "1.5rem",
|
||||
maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto",
|
||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13, color: "#374151" }}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: "0.35rem 0.75rem", border: "1px solid #d1d5db",
|
||||
borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db",
|
||||
borderRadius: 4, fontSize: 14, boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Staff } from "@groombook/types";
|
||||
|
||||
interface StaffForm {
|
||||
name: string;
|
||||
email: string;
|
||||
role: "groomer" | "receptionist" | "manager";
|
||||
}
|
||||
|
||||
const EMPTY_FORM: StaffForm = { name: "", email: "", role: "groomer" };
|
||||
|
||||
export function StaffPage() {
|
||||
const [staff, setStaff] = useState<Staff[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<Staff | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState<StaffForm>(EMPTY_FORM);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function load() {
|
||||
const r = await fetch("/api/staff?includeInactive=true");
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
setStaff((await r.json()) as Staff[]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function openNew() {
|
||||
setEditing(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
function openEdit(s: Staff) {
|
||||
setEditing(s);
|
||||
setForm({ name: s.name, email: s.email, role: s.role });
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const res = editing
|
||||
? await fetch(`/api/staff/${editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: form.name, role: form.role }) })
|
||||
: await fetch("/api/staff", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) });
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
setShowForm(false);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
setFormError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(s: Staff) {
|
||||
await fetch(`/api/staff/${s.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ active: !s.active }) });
|
||||
await load();
|
||||
}
|
||||
|
||||
if (loading) return <p style={{ padding: "1rem" }}>Loading…</p>;
|
||||
if (error) return <p style={{ padding: "1rem", color: "red" }}>Error: {error}</p>;
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: "system-ui, sans-serif" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1rem" }}>
|
||||
<h1 style={{ margin: 0 }}>Staff</h1>
|
||||
<button onClick={openNew} style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6", marginLeft: "auto" }}>
|
||||
+ Add Staff
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{staff.length === 0 ? (
|
||||
<p>No staff members yet.</p>
|
||||
) : (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr style={{ background: "#f8fafc" }}>
|
||||
{["Name", "Email", "Role", "Status", ""].map((h) => (
|
||||
<th key={h} style={{ textAlign: "left", padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0" }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{staff.map((s) => (
|
||||
<tr key={s.id} style={{ opacity: s.active ? 1 : 0.5 }}>
|
||||
<td style={tdStyle}>{s.name}</td>
|
||||
<td style={tdStyle}>{s.email}</td>
|
||||
<td style={tdStyle}><span style={{ textTransform: "capitalize" }}>{s.role}</span></td>
|
||||
<td style={tdStyle}>
|
||||
<span style={{ padding: "2px 8px", borderRadius: 12, fontSize: 11, fontWeight: 600, background: s.active ? "#d1fae5" : "#f3f4f6", color: s.active ? "#065f46" : "#6b7280" }}>
|
||||
{s.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ ...tdStyle, whiteSpace: "nowrap" }}>
|
||||
<button onClick={() => openEdit(s)} style={{ ...btnStyle, marginRight: "0.4rem" }}>Edit</button>
|
||||
<button onClick={() => toggleActive(s)} style={btnStyle}>{s.active ? "Deactivate" : "Activate"}</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<div
|
||||
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowForm(false); }}
|
||||
>
|
||||
<div style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 400, width: "calc(100% - 2rem)", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}>
|
||||
<h2 style={{ marginTop: 0 }}>{editing ? "Edit Staff" : "New Staff Member"}</h2>
|
||||
<form onSubmit={submit}>
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<label style={labelStyle}>Full name</label>
|
||||
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
||||
</div>
|
||||
{!editing && (
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<label style={labelStyle}>Email</label>
|
||||
<input type="email" value={form.email} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} required style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<label style={labelStyle}>Role</label>
|
||||
<select value={form.role} onChange={(e) => setForm((f) => ({ ...f, role: e.target.value as StaffForm["role"] }))} style={inputStyle}>
|
||||
<option value="groomer">Groomer</option>
|
||||
<option value="receptionist">Receptionist</option>
|
||||
<option value="manager">Manager</option>
|
||||
</select>
|
||||
</div>
|
||||
{formError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{formError}</p>}
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
|
||||
<button type="submit" disabled={saving} style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}>
|
||||
{saving ? "Saving…" : editing ? "Save Changes" : "Add Staff"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowForm(false)} style={btnStyle}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = { padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13 };
|
||||
const inputStyle: React.CSSProperties = { width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", borderRadius: 4, fontSize: 14, boxSizing: "border-box" };
|
||||
const labelStyle: React.CSSProperties = { display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13, color: "#374151" };
|
||||
const tdStyle: React.CSSProperties = { padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0" };
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
"postgres": "^3.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import postgres from "postgres";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
export * from "./schema.js";
|
||||
export { and, asc, desc, eq, gte, gt, lt, lte, ne, or, sql } from "drizzle-orm";
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
|
||||
Generated
+6277
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user