feat: appointment scheduling, client/pet/service/staff CRUD UI

- Weekly calendar view with navigation, color-coded by status
- Booking form with client→pet→service→staff→date/time flow
- Double-booking conflict detection on POST/PATCH appointments
- DELETE /api/appointments endpoint
- Staff API route (/api/staff) with full CRUD
- Clients page: searchable list, create/edit clients, add/edit pets
- Services page: table with create/edit/toggle-active
- Staff page: table with create/edit/toggle-active
- Nav bar with active-link highlighting, Staff link added

Resolves GitHub groombook/groombook#1, #2, #8

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Groom Book CTO
2026-03-17 17:45:46 +00:00
parent f4101982bb
commit eb56157d7d
8 changed files with 1544 additions and 88 deletions
+2
View File
@@ -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}`);
+91 -8
View File
@@ -1,7 +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 { and, eq, gte, lt, lte, ne, or } from "drizzle-orm";
import { getDb, appointments } from "@groombook/db";
export const appointmentsRouter = new Hono();
@@ -35,15 +35,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 +104,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 +134,58 @@ 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);
if (existing.length === 0) return c.json({ error: "Not found" }, 404);
const start = body.startTime
? new Date(body.startTime)
: existing[0].startTime;
const end = body.endTime ? new Date(body.endTime) : existing[0].endTime;
const staffId = body.staffId ?? existing[0].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 });
});
+65
View File
@@ -0,0 +1,65 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { 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 });
});