feat: appointment scheduling, client/pet/service/staff CRUD UI
* 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> * fix: remove unused import, fix useCallback deps - Remove unused `or` import from drizzle-orm in appointments route - Compute week end directly in loadAppointments callback to avoid exhaustive-deps lint warning (weekEnd derived from weekStart) Co-Authored-By: Paperclip <noreply@paperclip.ing> * chore: add pnpm lockfile Required for CI --frozen-lockfile installs. Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix: resolve all typecheck, lint, and test failures - Add @types/node to packages/db devDependencies (typecheck was missing process) - Re-export drizzle-orm helpers (eq, gte, etc.) from @groombook/db to avoid duplicate-instance type conflicts; remove drizzle-orm direct dep from API - Add @hono/zod-validator and jose as direct API dependencies - Merge duplicate @groombook/db imports in all route files - Fix noUncheckedIndexedAccess errors: appointments PATCH, web calendar grid - Fix weightKg/dateOfBirth type conversion in pets route (numeric→string, string→Date) - Add eslint.config.js for API and web (ESLint 9 flat config format) - Add vitest.config.ts with passWithNoTests for API and web Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Groom Book CTO <cto@groombook.app> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #15.
This commit is contained in:
committed by
GitHub
parent
f4101982bb
commit
4f92b8bffb
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user