feat(portal): replace mock data with real session-driven API calls #152
+20
-3
@@ -19,9 +19,10 @@ import { impersonationRouter } from "./routes/impersonation.js";
|
|||||||
import { settingsRouter } from "./routes/settings.js";
|
import { settingsRouter } from "./routes/settings.js";
|
||||||
import { searchRouter } from "./routes/search.js";
|
import { searchRouter } from "./routes/search.js";
|
||||||
import { calendarRouter } from "./routes/calendar.js";
|
import { calendarRouter } from "./routes/calendar.js";
|
||||||
import { getDb, businessSettings } from "@groombook/db";
|
import { setupRouter } from "./routes/setup.js";
|
||||||
|
import { getDb, businessSettings, eq, staff } from "@groombook/db";
|
||||||
import { authMiddleware } from "./middleware/auth.js";
|
import { authMiddleware } from "./middleware/auth.js";
|
||||||
import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js";
|
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
|
||||||
import { devRouter } from "./routes/dev.js";
|
import { devRouter } from "./routes/dev.js";
|
||||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||||
import { startReminderScheduler } from "./services/reminders.js";
|
import { startReminderScheduler } from "./services/reminders.js";
|
||||||
@@ -67,6 +68,17 @@ app.get("/api/branding", async (c) => {
|
|||||||
// Public iCal calendar feed — token auth in URL, no auth middleware required
|
// Public iCal calendar feed — token auth in URL, no auth middleware required
|
||||||
app.route("/api/calendar", calendarRouter);
|
app.route("/api/calendar", calendarRouter);
|
||||||
|
|
||||||
|
// Public setup status — no auth required, must be registered before auth middleware
|
||||||
|
app.get("/api/setup/status", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const [superUser] = await db
|
||||||
|
.select({ id: staff.id })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.isSuperUser, true))
|
||||||
|
.limit(1);
|
||||||
|
return c.json({ needsSetup: !superUser });
|
||||||
|
});
|
||||||
|
|
||||||
// Protected API routes
|
// Protected API routes
|
||||||
const api = app.basePath("/api");
|
const api = app.basePath("/api");
|
||||||
api.use("*", authMiddleware);
|
api.use("*", authMiddleware);
|
||||||
@@ -82,8 +94,10 @@ api.route("/auth", authRouter);
|
|||||||
// Manager-only: admin settings, reports, invoices, impersonation
|
// Manager-only: admin settings, reports, invoices, impersonation
|
||||||
// Staff CRUD: all roles may READ; manager-only for CREATE/UPDATE/DELETE
|
// Staff CRUD: all roles may READ; manager-only for CREATE/UPDATE/DELETE
|
||||||
api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer"));
|
api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer"));
|
||||||
api.use("/staff/*", requireRole("manager"));
|
// Staff write routes: manager OR super-user (combined guard — avoids AND stacking)
|
||||||
|
api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
|
||||||
api.use("/admin/*", requireRole("manager"));
|
api.use("/admin/*", requireRole("manager"));
|
||||||
|
api.use("/admin/settings/*", requireSuperUser());
|
||||||
api.use("/reports/*", requireRole("manager"));
|
api.use("/reports/*", requireRole("manager"));
|
||||||
api.use("/invoices/*", requireRole("manager"));
|
api.use("/invoices/*", requireRole("manager"));
|
||||||
api.use("/impersonation/*", requireRole("manager"));
|
api.use("/impersonation/*", requireRole("manager"));
|
||||||
@@ -123,6 +137,9 @@ api.on(
|
|||||||
);
|
);
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Setup: POST /api/setup (authenticated) — requires staff context from auth middleware
|
||||||
|
api.route("/setup", setupRouter);
|
||||||
|
|
||||||
api.route("/clients", clientsRouter);
|
api.route("/clients", clientsRouter);
|
||||||
api.route("/pets", petsRouter);
|
api.route("/pets", petsRouter);
|
||||||
api.route("/services", servicesRouter);
|
api.route("/services", servicesRouter);
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
if (!manager) {
|
if (!manager) {
|
||||||
return c.json({ error: "Forbidden: no staff records found" }, 403);
|
return c.json({ error: "Forbidden: no staff records found" }, 403);
|
||||||
}
|
}
|
||||||
c.set("staff", manager);
|
c.set("staff", { ...manager, isSuperUser: true });
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
.from(staff)
|
.from(staff)
|
||||||
.where(eq(staff.userId, devUserId));
|
.where(eq(staff.userId, devUserId));
|
||||||
if (row) {
|
if (row) {
|
||||||
c.set("staff", row);
|
c.set("staff", { ...row, isSuperUser: true });
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,7 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
403
|
403
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
c.set("staff", fallbackRow);
|
c.set("staff", { ...fallbackRow, isSuperUser: true });
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -125,3 +125,58 @@ export function requireRole(
|
|||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware that allows access if the staff member has any of the allowed roles OR is a super user.
|
||||||
|
* Use for routes where managers OR super-users should have access.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
|
||||||
|
*/
|
||||||
|
export function requireRoleOrSuperUser(
|
||||||
|
...allowedRoles: StaffRole[]
|
||||||
|
): MiddlewareHandler<AppEnv> {
|
||||||
|
return async (c, next) => {
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
if (!staffRow) {
|
||||||
|
return c.json({ error: "Forbidden: staff record not resolved" }, 403);
|
||||||
|
}
|
||||||
|
const hasAllowedRole = (allowedRoles as string[]).includes(staffRow.role);
|
||||||
|
if (hasAllowedRole || staffRow.isSuperUser) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: staffRow.isSuperUser
|
||||||
|
? `Forbidden: role '${staffRow.role}' is not permitted`
|
||||||
|
: "Forbidden: super user privileges required",
|
||||||
|
},
|
||||||
|
403
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware that enforces the staff member is a super user.
|
||||||
|
* Must be applied after resolveStaffMiddleware and (typically) after requireRole.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* api.use("/staff/*", requireRole("manager"));
|
||||||
|
* api.use("/staff/*", requireSuperUser());
|
||||||
|
*/
|
||||||
|
export function requireSuperUser(): MiddlewareHandler<AppEnv> {
|
||||||
|
return async (c, next) => {
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
if (!staffRow) {
|
||||||
|
return c.json({ error: "Forbidden: staff record not resolved" }, 403);
|
||||||
|
}
|
||||||
|
if (!staffRow.isSuperUser) {
|
||||||
|
return c.json(
|
||||||
|
{ error: "Forbidden: super user privileges required" },
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+136
-155
@@ -1,11 +1,135 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { and, eq, lt, gt, ne, getDb, appointments, impersonationSessions, waitlistEntries } from "@groombook/db";
|
import { and, eq, inArray } from "@groombook/db";
|
||||||
|
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const portalRouter = new Hono<AppEnv>();
|
export const portalRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
// ─── Session helper ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getClientIdFromSession(sessionId: string | null | undefined): Promise<string | null> {
|
||||||
|
if (!sessionId) return null;
|
||||||
|
const db = getDb();
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||||
|
.limit(1);
|
||||||
|
if (!session || session.expiresAt <= new Date()) return null;
|
||||||
|
return session.clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET routes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
portalRouter.get("/me", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||||
|
if (!client) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
|
||||||
|
});
|
||||||
|
|
||||||
|
portalRouter.get("/services", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const allServices = await db.select().from(services).where(eq(services.active, true));
|
||||||
|
return c.json(allServices.map(s => ({ id: s.id, name: s.name, description: s.description, basePriceCents: s.basePriceCents, durationMinutes: s.durationMinutes })));
|
||||||
|
});
|
||||||
|
|
||||||
|
portalRouter.get("/appointments", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const allAppts = await db
|
||||||
|
.select({
|
||||||
|
id: appointments.id,
|
||||||
|
startTime: appointments.startTime,
|
||||||
|
endTime: appointments.endTime,
|
||||||
|
status: appointments.status,
|
||||||
|
confirmationStatus: appointments.confirmationStatus,
|
||||||
|
customerNotes: appointments.customerNotes,
|
||||||
|
notes: appointments.notes,
|
||||||
|
petId: appointments.petId,
|
||||||
|
serviceId: appointments.serviceId,
|
||||||
|
staffId: appointments.staffId,
|
||||||
|
})
|
||||||
|
.from(appointments)
|
||||||
|
.where(eq(appointments.clientId, clientId))
|
||||||
|
.orderBy(appointments.startTime);
|
||||||
|
|
||||||
|
const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null);
|
||||||
|
const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null);
|
||||||
|
|
||||||
|
const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : [];
|
||||||
|
const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : [];
|
||||||
|
|
||||||
|
const petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
|
||||||
|
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
|
||||||
|
|
||||||
|
const appts = allAppts.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
startTime: a.startTime,
|
||||||
|
endTime: a.endTime,
|
||||||
|
status: a.status,
|
||||||
|
confirmationStatus: a.confirmationStatus,
|
||||||
|
customerNotes: a.customerNotes,
|
||||||
|
notes: a.notes,
|
||||||
|
pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null,
|
||||||
|
service: a.serviceId ? { id: a.serviceId } : null,
|
||||||
|
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const upcoming = appts.filter(a => a.startTime > now && a.status !== "cancelled");
|
||||||
|
const past = appts.filter(a => a.startTime <= now || a.status === "cancelled");
|
||||||
|
|
||||||
|
return c.json({ upcoming, past });
|
||||||
|
});
|
||||||
|
|
||||||
|
portalRouter.get("/pets", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
||||||
|
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
||||||
|
});
|
||||||
|
|
||||||
|
portalRouter.get("/invoices", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
||||||
|
const invoiceIds = clientInvoices.map(i => i.id);
|
||||||
|
const lineItems = invoiceIds.length ? await db.select().from(invoiceLineItems).where(inArray(invoiceLineItems.invoiceId, invoiceIds)) : [];
|
||||||
|
|
||||||
|
const itemsByInvoice: Record<string, typeof lineItems> = {};
|
||||||
|
for (const li of lineItems) {
|
||||||
|
if (!itemsByInvoice[li.invoiceId]) itemsByInvoice[li.invoiceId] = [];
|
||||||
|
itemsByInvoice[li.invoiceId]!.push(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(clientInvoices.map(inv => ({
|
||||||
|
id: inv.id,
|
||||||
|
status: inv.status,
|
||||||
|
totalCents: inv.totalCents,
|
||||||
|
createdAt: inv.createdAt,
|
||||||
|
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Appointment action routes ────────────────────────────────────────────────
|
||||||
|
|
||||||
const customerNotesSchema = z.object({
|
const customerNotesSchema = z.object({
|
||||||
// .min(1) prevents empty strings — clearing notes is not a supported use case
|
// .min(1) prevents empty strings — clearing notes is not a supported use case
|
||||||
customerNotes: z.string().min(1).max(500),
|
customerNotes: z.string().min(1).max(500),
|
||||||
@@ -20,27 +144,11 @@ portalRouter.patch(
|
|||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
if (!sessionId) {
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) {
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [session] = await db
|
|
||||||
.select()
|
|
||||||
.from(impersonationSessions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(impersonationSessions.id, sessionId),
|
|
||||||
eq(impersonationSessions.status, "active")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!session || session.expiresAt <= new Date()) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authClientId = session.clientId;
|
|
||||||
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
@@ -51,7 +159,7 @@ portalRouter.patch(
|
|||||||
return c.json({ error: "Not found" }, 404);
|
return c.json({ error: "Not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appt.clientId !== authClientId) {
|
if (appt.clientId !== clientId) {
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,22 +192,8 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
|
|||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
if (!sessionId) {
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
if (!clientId) {
|
||||||
}
|
|
||||||
|
|
||||||
const [session] = await db
|
|
||||||
.select()
|
|
||||||
.from(impersonationSessions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(impersonationSessions.id, sessionId),
|
|
||||||
eq(impersonationSessions.status, "active")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!session || session.expiresAt <= new Date()) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +207,7 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
|
|||||||
return c.json({ error: "Not found" }, 404);
|
return c.json({ error: "Not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appt.clientId !== session.clientId) {
|
if (appt.clientId !== clientId) {
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,22 +246,8 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
|
|||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
if (!sessionId) {
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
if (!clientId) {
|
||||||
}
|
|
||||||
|
|
||||||
const [session] = await db
|
|
||||||
.select()
|
|
||||||
.from(impersonationSessions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(impersonationSessions.id, sessionId),
|
|
||||||
eq(impersonationSessions.status, "active")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!session || session.expiresAt <= new Date()) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +261,7 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
|
|||||||
return c.json({ error: "Not found" }, 404);
|
return c.json({ error: "Not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appt.clientId !== session.clientId) {
|
if (appt.clientId !== clientId) {
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,106 +292,7 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Appointment reschedule ──────────────────────────────────────────────────
|
// ─── Client-facing waitlist routes ────────────────────────────────────────────
|
||||||
|
|
||||||
const rescheduleSchema = z.object({
|
|
||||||
startTime: z.string().datetime(),
|
|
||||||
});
|
|
||||||
|
|
||||||
portalRouter.post(
|
|
||||||
"/appointments/:id/reschedule",
|
|
||||||
zValidator("json", rescheduleSchema),
|
|
||||||
async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const id = c.req.param("id");
|
|
||||||
const body = c.req.valid("json");
|
|
||||||
|
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
|
||||||
if (!sessionId) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [session] = await db
|
|
||||||
.select()
|
|
||||||
.from(impersonationSessions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(impersonationSessions.id, sessionId),
|
|
||||||
eq(impersonationSessions.status, "active")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!session || session.expiresAt <= new Date()) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [appt] = await db
|
|
||||||
.select()
|
|
||||||
.from(appointments)
|
|
||||||
.where(eq(appointments.id, id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!appt) {
|
|
||||||
return c.json({ error: "Not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appt.clientId !== session.clientId) {
|
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appt.startTime <= new Date()) {
|
|
||||||
return c.json({ error: "Cannot reschedule a past or in-progress appointment" }, 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appt.status === "cancelled" || appt.status === "completed") {
|
|
||||||
return c.json({ error: "Cannot reschedule a cancelled or completed appointment" }, 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newStart = new Date(body.startTime);
|
|
||||||
const durationMs = appt.endTime.getTime() - appt.startTime.getTime();
|
|
||||||
const newEnd = new Date(newStart.getTime() + durationMs);
|
|
||||||
|
|
||||||
const [existingConflict] = await db
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(appointments.staffId, appt.staffId!),
|
|
||||||
lt(appointments.startTime, newEnd),
|
|
||||||
gt(appointments.endTime, newStart),
|
|
||||||
ne(appointments.status, "cancelled"),
|
|
||||||
ne(appointments.status, "no_show"),
|
|
||||||
ne(appointments.id, id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existingConflict) {
|
|
||||||
return c.json({ error: "The selected time slot is no longer available" }, 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updated] = await db
|
|
||||||
.update(appointments)
|
|
||||||
.set({ startTime: newStart, endTime: newEnd, updatedAt: new Date() })
|
|
||||||
.where(eq(appointments.id, id))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
return c.json({ error: "Not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
id: updated.id,
|
|
||||||
startTime: updated.startTime,
|
|
||||||
endTime: updated.endTime,
|
|
||||||
status: updated.status,
|
|
||||||
updatedAt: updated.updatedAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Client-facing waitlist routes ───────────────────────────────────────────
|
|
||||||
|
|
||||||
const createWaitlistEntrySchema = z.object({
|
const createWaitlistEntrySchema = z.object({
|
||||||
petId: z.string().uuid(),
|
petId: z.string().uuid(),
|
||||||
@@ -465,4 +446,4 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { z } from "zod/v3";
|
||||||
|
import { eq, getDb, staff, businessSettings } from "@groombook/db";
|
||||||
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
|
export const setupRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
||||||
|
setupRouter.get("/status", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Check if any super user exists
|
||||||
|
const [superUser] = await db
|
||||||
|
.select({ id: staff.id })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.isSuperUser, true))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return c.json({ needsSetup: !superUser });
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupSchema = z.object({
|
||||||
|
businessName: z.string().min(1).max(200),
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/setup — authenticated, marks current staff as super user and sets business name
|
||||||
|
setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
const currentStaff = c.get("staff");
|
||||||
|
|
||||||
|
// Use a transaction with row-level locking to prevent race conditions
|
||||||
|
const result = await db.transaction(async (tx) => {
|
||||||
|
// Lock the business_settings row for update to prevent concurrent setup
|
||||||
|
const [existingSettings] = await tx
|
||||||
|
.select({ id: businessSettings.id })
|
||||||
|
.from(businessSettings)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// Lock super user rows to prevent concurrent claims
|
||||||
|
// FOR UPDATE serializes concurrent claims: second transaction blocks until first commits
|
||||||
|
const [existingSuperUser] = await tx
|
||||||
|
.select({ id: staff.id })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.isSuperUser, true))
|
||||||
|
.for("update")
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingSuperUser) {
|
||||||
|
return { error: "Setup has already been completed. A super user already exists.", code: 409 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or create business settings with the business name
|
||||||
|
if (existingSettings) {
|
||||||
|
await tx
|
||||||
|
.update(businessSettings)
|
||||||
|
.set({ businessName: body.businessName, updatedAt: new Date() })
|
||||||
|
.where(eq(businessSettings.id, existingSettings.id));
|
||||||
|
} else {
|
||||||
|
await tx.insert(businessSettings).values({ businessName: body.businessName });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the current staff as super user
|
||||||
|
const [updatedStaff] = await tx
|
||||||
|
.update(staff)
|
||||||
|
.set({ isSuperUser: true, updatedAt: new Date() })
|
||||||
|
.where(eq(staff.id, currentStaff.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return { staff: updatedStaff };
|
||||||
|
});
|
||||||
|
|
||||||
|
if ("error" in result) {
|
||||||
|
return c.json({ error: result.error }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ok: true, staff: result.staff }, 201);
|
||||||
|
});
|
||||||
@@ -53,7 +53,7 @@ test("clients page shows client list", async ({ page }) => {
|
|||||||
|
|
||||||
test("clients page shows search input", async ({ page }) => {
|
test("clients page shows search input", async ({ page }) => {
|
||||||
await page.goto("/admin/clients");
|
await page.goto("/admin/clients");
|
||||||
await expect(page.getByPlaceholder(/search/i)).toBeVisible();
|
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking a client shows their details", async ({ page }) => {
|
test("clicking a client shows their details", async ({ page }) => {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Ignore untracked .js files containing JSX (build artifacts)
|
||||||
|
src/__tests__/*.js
|
||||||
|
src/portal/sections/*.js
|
||||||
|
src/portal/*.js
|
||||||
|
src/pages/*.js
|
||||||
|
src/components/*.js
|
||||||
|
src/lib/*.js
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
// Untracked .js files containing JSX (build artifacts)
|
||||||
|
"src/**/*.js",
|
||||||
|
"src/**/*.jsx",
|
||||||
|
],
|
||||||
|
},
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
+36
-4
@@ -12,6 +12,7 @@ import { SettingsPage } from "./pages/Settings.js";
|
|||||||
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
||||||
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
||||||
import { BookingErrorPage } from "./pages/BookingError.js";
|
import { BookingErrorPage } from "./pages/BookingError.js";
|
||||||
|
import { SetupWizard } from "./pages/SetupWizard.jsx";
|
||||||
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||||
@@ -189,6 +190,7 @@ function AdminLayout() {
|
|||||||
export function App() {
|
export function App() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [authDisabled, setAuthDisabled] = useState<boolean | null>(null);
|
const [authDisabled, setAuthDisabled] = useState<boolean | null>(null);
|
||||||
|
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
|
||||||
const { data: rawSession, isPending: rawSessionLoading } = useSession();
|
const { data: rawSession, isPending: rawSessionLoading } = useSession();
|
||||||
// In dev mode (authDisabled=true), session state is irrelevant - skip useSession result
|
// In dev mode (authDisabled=true), session state is irrelevant - skip useSession result
|
||||||
const session = authDisabled ? null : rawSession;
|
const session = authDisabled ? null : rawSession;
|
||||||
@@ -201,6 +203,19 @@ export function App() {
|
|||||||
.catch(() => setAuthDisabled(false));
|
.catch(() => setAuthDisabled(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// After session is confirmed, check if setup is needed
|
||||||
|
useEffect(() => {
|
||||||
|
if (authDisabled === null || sessionLoading) return;
|
||||||
|
// Skip if no authenticated session (will redirect to login or dev selector)
|
||||||
|
if (!authDisabled && !session) return;
|
||||||
|
if (authDisabled && !getDevUser()) return;
|
||||||
|
|
||||||
|
fetch("/api/setup/status")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setNeedsSetup(data.needsSetup === true))
|
||||||
|
.catch(() => setNeedsSetup(false));
|
||||||
|
}, [authDisabled, session, sessionLoading]);
|
||||||
|
|
||||||
// Public booking redirect pages — no auth or portal chrome needed
|
// Public booking redirect pages — no auth or portal chrome needed
|
||||||
if (location.pathname === "/booking/confirmed") {
|
if (location.pathname === "/booking/confirmed") {
|
||||||
return <BookingConfirmedPage />;
|
return <BookingConfirmedPage />;
|
||||||
@@ -212,24 +227,41 @@ export function App() {
|
|||||||
return <BookingErrorPage />;
|
return <BookingErrorPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Still loading auth state
|
// Setup wizard — standalone, no admin chrome
|
||||||
|
if (location.pathname === "/setup") {
|
||||||
|
return (
|
||||||
|
<BrandingProvider>
|
||||||
|
<SetupWizard />
|
||||||
|
</BrandingProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still loading auth state or setup check (skip setup check in dev mode)
|
||||||
if (authDisabled === null || sessionLoading) return null;
|
if (authDisabled === null || sessionLoading) return null;
|
||||||
|
|
||||||
// Dev mode: show login selector
|
// Dev mode: show login selector (no setup check needed in dev mode)
|
||||||
if (authDisabled && location.pathname === "/login") {
|
if (authDisabled && location.pathname === "/login") {
|
||||||
return <DevLoginSelector />;
|
return <DevLoginSelector />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dev mode: use dev login selector
|
// Dev mode: use dev login selector (no setup check needed in dev mode)
|
||||||
if (authDisabled && !getDevUser()) {
|
if (authDisabled && !getDevUser()) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production mode: if no session, show login page (avoids redirect loops)
|
// Production: need setup check
|
||||||
|
if (needsSetup === null) return null;
|
||||||
|
|
||||||
|
// Production mode: if no session, redirect to Authentik sign-in
|
||||||
if (!authDisabled && !session) {
|
if (!authDisabled && !session) {
|
||||||
return <LoginPage />;
|
return <LoginPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redirect to setup wizard if needed
|
||||||
|
if (needsSetup) {
|
||||||
|
return <Navigate to="/setup" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrandingProvider>
|
<BrandingProvider>
|
||||||
{location.pathname.startsWith("/admin") ? (
|
{location.pathname.startsWith("/admin") ? (
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import type { Appointment } from "../portal/mockData.js";
|
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx";
|
||||||
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.js";
|
|
||||||
|
|
||||||
const UPCOMING_APPT: Appointment = {
|
const UPCOMING_APPT = {
|
||||||
id: "appt-1",
|
id: "appt-1",
|
||||||
petId: "pet-1",
|
petId: "pet-1",
|
||||||
petName: "Buddy",
|
petName: "Buddy",
|
||||||
groomerId: "groomer-1",
|
groomerId: "groomer-1",
|
||||||
groomerName: "Sarah",
|
groomerName: "Sarah",
|
||||||
services: ["Bath & Brush"],
|
services: ["Bath & Brush"],
|
||||||
|
serviceId: "service-1",
|
||||||
addOns: [],
|
addOns: [],
|
||||||
date: "2027-01-01",
|
date: "2027-01-01",
|
||||||
time: "10:00 AM",
|
time: "10:00 AM",
|
||||||
duration: 60,
|
duration: 60,
|
||||||
price: 50,
|
price: 50,
|
||||||
status: "confirmed",
|
status: "confirmed" as const,
|
||||||
notes: "",
|
notes: "",
|
||||||
customerNotes: "",
|
customerNotes: "",
|
||||||
confirmationStatus: "pending",
|
confirmationStatus: "pending" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAST_APPT: Appointment = {
|
const PAST_APPT = {
|
||||||
...UPCOMING_APPT,
|
...UPCOMING_APPT,
|
||||||
id: "appt-2",
|
id: "appt-2",
|
||||||
date: "2025-01-01",
|
date: "2025-01-01",
|
||||||
time: "10:00 AM",
|
time: "10:00 AM",
|
||||||
status: "completed",
|
status: "completed" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("parseTimeTo24Hour", () => {
|
describe("parseTimeTo24Hour", () => {
|
||||||
@@ -78,7 +78,7 @@ describe("CustomerNotesSection", () => {
|
|||||||
expect(screen.getByRole("button", { name: /Save Notes/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /Save Notes/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends X-Impersonation-Session-Id header when session exists", async () => {
|
it("sends Authorization header when session exists", async () => {
|
||||||
vi.mocked(global.fetch).mockResolvedValue({
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
|
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
|
||||||
@@ -93,14 +93,14 @@ describe("CustomerNotesSection", () => {
|
|||||||
"/api/portal/appointments/appt-1/notes",
|
"/api/portal/appointments/appt-1/notes",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: expect.objectContaining({
|
headers: expect.objectContaining({
|
||||||
"X-Impersonation-Session-Id": "test-session-id",
|
"Authorization": "Bearer test-session-id",
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send X-Impersonation-Session-Id header when sessionId is null", async () => {
|
it("does not send Authorization header when sessionId is null", async () => {
|
||||||
vi.mocked(global.fetch).mockResolvedValue({
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
|
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
|
||||||
@@ -115,7 +115,7 @@ describe("CustomerNotesSection", () => {
|
|||||||
"/api/portal/appointments/appt-1/notes",
|
"/api/portal/appointments/appt-1/notes",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: expect.not.objectContaining({
|
headers: expect.not.objectContaining({
|
||||||
"X-Impersonation-Session-Id": expect.anything(),
|
"Authorization": expect.anything(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -212,7 +212,7 @@ describe("ConfirmationSection", () => {
|
|||||||
|
|
||||||
it("renders confirmed badge when confirmationStatus is confirmed", () => {
|
it("renders confirmed badge when confirmationStatus is confirmed", () => {
|
||||||
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "confirmed" }} sessionId="test-session-id" />);
|
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "confirmed" }} sessionId="test-session-id" />);
|
||||||
expect(screen.getByText("✓ Confirmed")).toBeInTheDocument();
|
expect(screen.getByText("Confirmed")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders cancelled badge when confirmationStatus is cancelled", () => {
|
it("renders cancelled badge when confirmationStatus is cancelled", () => {
|
||||||
@@ -251,11 +251,11 @@ describe("ConfirmationSection", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("✓ Confirmed")).toBeInTheDocument();
|
expect(screen.getByText("Confirmed")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends X-Impersonation-Session-Id header when session exists", async () => {
|
it("sends Authorization header when session exists", async () => {
|
||||||
vi.mocked(global.fetch).mockResolvedValue({
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
||||||
@@ -269,14 +269,14 @@ describe("ConfirmationSection", () => {
|
|||||||
"/api/portal/appointments/appt-1/confirm",
|
"/api/portal/appointments/appt-1/confirm",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: expect.objectContaining({
|
headers: expect.objectContaining({
|
||||||
"X-Impersonation-Session-Id": "test-session-id",
|
"Authorization": "Bearer test-session-id",
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send X-Impersonation-Session-Id header when sessionId is null", async () => {
|
it("does not send Authorization header when sessionId is null", async () => {
|
||||||
vi.mocked(global.fetch).mockResolvedValue({
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
||||||
@@ -290,7 +290,7 @@ describe("ConfirmationSection", () => {
|
|||||||
"/api/portal/appointments/appt-1/confirm",
|
"/api/portal/appointments/appt-1/confirm",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: expect.not.objectContaining({
|
headers: expect.not.objectContaining({
|
||||||
"X-Impersonation-Session-Id": expect.anything(),
|
"Authorization": expect.anything(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
export { SetupWizard } from "./SetupWizard.jsx";
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useBranding } from "../BrandingContext.js";
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
|
||||||
|
{ title: "Business Name", description: "What is the name of your business?" },
|
||||||
|
{ title: "Super User", description: "You will be designated as a Super User with full administrative access." },
|
||||||
|
{ title: "Add Another Admin", description: "Consider adding a second Super User as a backup. This is optional but recommended." },
|
||||||
|
{ title: "All Set!", description: "Your GroomBook instance is ready to use." },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SetupWizard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { refresh: refreshBranding } = useBranding();
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [businessName, setBusinessName] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const current = STEPS[step];
|
||||||
|
const isLast = step === STEPS.length - 1;
|
||||||
|
const canGoBack = step > 0 && step < STEPS.length - 1;
|
||||||
|
const canGoNext = step < STEPS.length - 1 && (step !== 1 || businessName.trim().length > 0);
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
if (step === STEPS.length - 1) {
|
||||||
|
// Done - redirect to admin
|
||||||
|
navigate("/admin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === 1 && businessName.trim()) {
|
||||||
|
// Step 2 (index 1) -> Step 3 (index 2): submit setup
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/setup", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ businessName: businessName.trim() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data.error || "Setup failed. Please try again.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Refresh branding so the nav bar shows the new business name
|
||||||
|
refreshBranding();
|
||||||
|
} catch (e) {
|
||||||
|
setError("Network error. Please try again.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
setStep((s) => s + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (step > 0) setStep((s) => s - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "#f0f2f5",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: "0 4px 24px rgba(0,0,0,0.10)",
|
||||||
|
padding: "2.5rem 3rem",
|
||||||
|
maxWidth: 480,
|
||||||
|
width: "100%",
|
||||||
|
}}>
|
||||||
|
{/* Progress dots */}
|
||||||
|
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
|
||||||
|
{STEPS.map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: i === step ? "#4f8a6f" : i < step ? "#4f8a6f" : "#e2e8f0",
|
||||||
|
opacity: i === step ? 1 : i < step ? 0.5 : 1,
|
||||||
|
transition: "background 0.2s",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step indicator */}
|
||||||
|
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
|
||||||
|
Step {step + 1} of {STEPS.length}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
|
||||||
|
{current.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
|
||||||
|
{current.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Step 2: Business name input */}
|
||||||
|
{step === 1 && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Happy Paws Grooming"
|
||||||
|
value={businessName}
|
||||||
|
onChange={(e) => setBusinessName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()}
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.6rem 0.85rem",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
fontSize: 15,
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
marginBottom: error ? "0.5rem" : 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Info about super user */}
|
||||||
|
{step === 2 && (
|
||||||
|
<div style={{
|
||||||
|
background: "#f0fdf4",
|
||||||
|
border: "1px solid #bbf7d0",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "0.85rem 1rem",
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#166534",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
}}>
|
||||||
|
As a Super User, you can manage all settings, staff, and appointments.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Info about second admin */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div style={{
|
||||||
|
background: "#fffbeb",
|
||||||
|
border: "1px solid #fde68a",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "0.85rem 1rem",
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#92400e",
|
||||||
|
}}>
|
||||||
|
You can add additional Super Users from the Staff management page after setup.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<p style={{
|
||||||
|
margin: "0.5rem 0 0",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#dc2626",
|
||||||
|
background: "#fef2f2",
|
||||||
|
border: "1px solid #fecaca",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation buttons */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.75rem",
|
||||||
|
marginTop: step === 3 ? "1.5rem" : "1.25rem",
|
||||||
|
justifyContent: step === 0 ? "flex-end" : "space-between",
|
||||||
|
}}>
|
||||||
|
{canGoBack && (
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
padding: "0.55rem 1.1rem",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
background: "#fff",
|
||||||
|
color: "#374151",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: loading ? "not-allowed" : "pointer",
|
||||||
|
opacity: loading ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!canGoNext || loading}
|
||||||
|
style={{
|
||||||
|
padding: "0.55rem 1.25rem",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "none",
|
||||||
|
background: canGoNext && !loading ? "#4f8a6f" : "#9ca3af",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: canGoNext && !loading ? "pointer" : "not-allowed",
|
||||||
|
opacity: loading ? 0.7 : 1,
|
||||||
|
marginLeft: canGoBack ? 0 : "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "Setting up..." : isLast ? "Go to Dashboard" : step === 1 ? "Continue" : "Next"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ import { Communication } from "./sections/Communication.js";
|
|||||||
import { AccountSettings } from "./sections/AccountSettings.js";
|
import { AccountSettings } from "./sections/AccountSettings.js";
|
||||||
import { ImpersonationBanner } from "./ImpersonationBanner.js";
|
import { ImpersonationBanner } from "./ImpersonationBanner.js";
|
||||||
import { AuditLogViewer } from "./AuditLogViewer.js";
|
import { AuditLogViewer } from "./AuditLogViewer.js";
|
||||||
import { CUSTOMER } from "./mockData.js";
|
|
||||||
import { useBranding } from "../BrandingContext.js";
|
import { useBranding } from "../BrandingContext.js";
|
||||||
import type { ImpersonationSession } from "@groombook/types";
|
import type { ImpersonationSession } from "@groombook/types";
|
||||||
|
|
||||||
@@ -37,6 +36,7 @@ export function CustomerPortal() {
|
|||||||
const [rescheduleAppointment, setRescheduleAppointment] = useState<Record<string, unknown> | null>(null);
|
const [rescheduleAppointment, setRescheduleAppointment] = useState<Record<string, unknown> | null>(null);
|
||||||
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
||||||
const [sessionExtended, setSessionExtended] = useState(false);
|
const [sessionExtended, setSessionExtended] = useState(false);
|
||||||
|
const [clientName, setClientName] = useState<string>("");
|
||||||
const { branding } = useBranding();
|
const { branding } = useBranding();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
@@ -57,6 +57,11 @@ export function CustomerPortal() {
|
|||||||
.then((s) => {
|
.then((s) => {
|
||||||
if (s && s.status === "active") {
|
if (s && s.status === "active") {
|
||||||
setSession(s);
|
setSession(s);
|
||||||
|
// Fetch client name for display
|
||||||
|
fetch(`/api/portal/me`, { headers: { "X-Impersonation-Session-Id": s.id } })
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => { if (data?.name) setClientName(data.name); })
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
// Clean sessionId from URL
|
// Clean sessionId from URL
|
||||||
setSearchParams({}, { replace: true });
|
setSearchParams({}, { replace: true });
|
||||||
@@ -109,32 +114,37 @@ export function CustomerPortal() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReschedule = useCallback((appointment: Record<string, unknown>) => {
|
const handleReschedule = useCallback((appointmentId: string) => {
|
||||||
setRescheduleAppointment(appointment);
|
// Look up the full appointment from Dashboard's displayed data
|
||||||
|
// The appointment was already fetched by Dashboard, so we use the ID to find it
|
||||||
|
setRescheduleAppointment({ id: appointmentId } as Record<string, unknown>);
|
||||||
setShowReschedule(true);
|
setShowReschedule(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isReadOnly = session?.status === "active";
|
const isReadOnly = session?.status === "active";
|
||||||
|
|
||||||
const renderSection = () => {
|
const renderSection = () => {
|
||||||
|
const sessionId = session?.id ?? null;
|
||||||
switch (activeSection) {
|
switch (activeSection) {
|
||||||
case "dashboard":
|
case "dashboard":
|
||||||
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} onReschedule={handleReschedule} />;
|
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} sessionId={sessionId} clientName={clientName} onReschedule={handleReschedule} />;
|
||||||
case "appointments":
|
case "appointments":
|
||||||
return <AppointmentsSection readOnly={!!isReadOnly} sessionId={session?.id ?? null} />;
|
return <AppointmentsSection readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||||
case "pets":
|
case "pets":
|
||||||
return <PetProfiles readOnly={!!isReadOnly} />;
|
return <PetProfiles readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||||
case "reports":
|
case "reports":
|
||||||
return <ReportCards />;
|
return <ReportCards />;
|
||||||
case "billing":
|
case "billing":
|
||||||
return <BillingPayments readOnly={!!isReadOnly} />;
|
return <BillingPayments readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||||
case "messages":
|
case "messages":
|
||||||
return <Communication readOnly={!!isReadOnly} />;
|
return <Communication readOnly={!!isReadOnly} />;
|
||||||
case "settings":
|
case "settings":
|
||||||
return <AccountSettings readOnly={!!isReadOnly} />;
|
return <AccountSettings readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const avatarInitials = (clientName.split(" ")[0] || "G").charAt(0).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="min-h-screen bg-[#faf8f5] font-sans"
|
className="min-h-screen bg-[#faf8f5] font-sans"
|
||||||
@@ -187,7 +197,7 @@ export function CustomerPortal() {
|
|||||||
</button>
|
</button>
|
||||||
<span className="text-lg font-semibold text-stone-800">{branding.businessName}</span>
|
<span className="text-lg font-semibold text-stone-800">{branding.businessName}</span>
|
||||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
|
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
|
||||||
SM
|
{avatarInitials}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -274,9 +284,9 @@ export function CustomerPortal() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm text-stone-600">Hi, {CUSTOMER.name.split(" ")[0]}</span>
|
<span className="text-sm text-stone-600">Hi, {clientName.split(" ")[0] || "Guest"}</span>
|
||||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
|
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
|
||||||
SM
|
{avatarInitials}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
import { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
|
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
|
||||||
import { CUSTOMER, PETS, SIGNED_AGREEMENTS } from "../mockData.js";
|
|
||||||
import { PetForm } from "./PetForm.js";
|
import { PetForm } from "./PetForm.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
sessionId: string | null;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountSettings({ readOnly }: Props) {
|
interface PersonalInfoData {
|
||||||
|
id?: string;
|
||||||
|
email?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
phone?: string;
|
||||||
|
address?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PetData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
species?: string;
|
||||||
|
breed?: string;
|
||||||
|
weight?: number;
|
||||||
|
photo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountSettings({ sessionId, readOnly }: Props) {
|
||||||
const [tab, setTab] = useState<"personal" | "password" | "pets" | "agreements">("personal");
|
const [tab, setTab] = useState<"personal" | "password" | "pets" | "agreements">("personal");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -32,21 +50,65 @@ export function AccountSettings({ readOnly }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === "personal" && <PersonalInfo readOnly={readOnly} />}
|
{tab === "personal" && <PersonalInfo sessionId={sessionId} readOnly={readOnly} />}
|
||||||
{tab === "password" && <PasswordChange readOnly={readOnly} />}
|
{tab === "password" && <PasswordChange readOnly={readOnly} />}
|
||||||
{tab === "pets" && <ManagePets readOnly={readOnly} />}
|
{tab === "pets" && <ManagePets sessionId={sessionId} readOnly={readOnly} />}
|
||||||
{tab === "agreements" && <Agreements />}
|
{tab === "agreements" && <Agreements />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PersonalInfo({ readOnly }: { readOnly: boolean }) {
|
function PersonalInfo({ sessionId, readOnly }: { sessionId: string | null; readOnly: boolean }) {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: CUSTOMER.name,
|
name: "",
|
||||||
email: CUSTOMER.email,
|
email: "",
|
||||||
phone: CUSTOMER.phone,
|
phone: "",
|
||||||
address: CUSTOMER.address,
|
address: "",
|
||||||
});
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPersonalInfo = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch("/api/portal/me");
|
||||||
|
if (response.ok) {
|
||||||
|
const data: PersonalInfoData = await response.json();
|
||||||
|
setForm({
|
||||||
|
name: [data.firstName, data.lastName].filter(Boolean).join(" ") || "",
|
||||||
|
email: data.email || "",
|
||||||
|
phone: data.phone || "",
|
||||||
|
address: data.address || "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setError("Failed to load personal info");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load personal info");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPersonalInfo();
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
|
<p className="text-sm text-stone-500">Loading personal info...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
|
<p className="text-sm text-red-500">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
@@ -112,15 +174,58 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ManagePets({ readOnly }: { readOnly: boolean }) {
|
function ManagePets({ sessionId, readOnly }: { sessionId: string | null; readOnly: boolean }) {
|
||||||
|
const [pets, setPets] = useState<PetData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const editingPet = editingPetId ? PETS.find(p => p.id === editingPetId) ?? undefined : undefined;
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPets = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch("/api/portal/pets");
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setPets(Array.isArray(data) ? data : []);
|
||||||
|
} else {
|
||||||
|
setError("Failed to load pets");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load pets");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPets();
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? undefined : undefined;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
|
<p className="text-sm text-stone-500">Loading pets...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
|
<p className="text-sm text-red-500">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (editingPet || showAddForm) {
|
if (editingPet || showAddForm) {
|
||||||
return (
|
return (
|
||||||
<PetForm
|
<PetForm
|
||||||
pet={editingPet ?? undefined}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
pet={(editingPet ?? undefined) as any}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
onSave={() => { setEditingPetId(null); setShowAddForm(false); }}
|
onSave={() => { setEditingPetId(null); setShowAddForm(false); }}
|
||||||
onCancel={() => { setEditingPetId(null); setShowAddForm(false); }}
|
onCancel={() => { setEditingPetId(null); setShowAddForm(false); }}
|
||||||
/>
|
/>
|
||||||
@@ -129,7 +234,7 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{PETS.map(pet => (
|
{pets.map(pet => (
|
||||||
<div key={pet.id} className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm flex items-center gap-4">
|
<div key={pet.id} className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm flex items-center gap-4">
|
||||||
<div className="w-14 h-14 rounded-xl bg-(--color-accent-light) flex items-center justify-center text-3xl">
|
<div className="w-14 h-14 rounded-xl bg-(--color-accent-light) flex items-center justify-center text-3xl">
|
||||||
{pet.photo}
|
{pet.photo}
|
||||||
@@ -168,31 +273,10 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
|
|||||||
|
|
||||||
function Agreements() {
|
function Agreements() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<p className="text-sm text-stone-500">
|
||||||
<table className="w-full text-sm">
|
No agreements found. There is currently no agreements table in the database.
|
||||||
<thead>
|
</p>
|
||||||
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
|
|
||||||
<th className="px-5 py-3 font-medium">Document</th>
|
|
||||||
<th className="px-5 py-3 font-medium">Date Signed</th>
|
|
||||||
<th className="px-5 py-3 font-medium"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{SIGNED_AGREEMENTS.map(agr => (
|
|
||||||
<tr key={agr.id} className="border-b border-stone-50">
|
|
||||||
<td className="px-5 py-3 font-medium text-stone-800">{agr.name}</td>
|
|
||||||
<td className="px-5 py-3 text-stone-600">
|
|
||||||
{new Date(agr.dateSigned).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3">
|
|
||||||
<button className="text-sm text-(--color-accent-dark) font-medium hover:underline">View</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,252 +1,191 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { CreditCard, Download, DollarSign, Package, Zap, Plus, Trash2 } from "lucide-react";
|
|
||||||
import { INVOICES, SAVED_PAYMENT_METHODS, PREPAID_PACKAGES } from "../mockData.js";
|
|
||||||
|
|
||||||
interface Props {
|
interface Invoice {
|
||||||
|
id: string;
|
||||||
|
status: "pending" | "paid" | "failed" | "refunded";
|
||||||
|
totalCents: number;
|
||||||
|
date: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentMethod {
|
||||||
|
brand: string;
|
||||||
|
last4: string;
|
||||||
|
expiryMonth: number;
|
||||||
|
expiryYear: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Package {
|
||||||
|
name: string;
|
||||||
|
remaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BillingPaymentsProps {
|
||||||
|
sessionId: string | null;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_STYLES: Record<string, string> = {
|
export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||||
paid: "bg-green-100 text-green-700",
|
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||||
outstanding: "bg-amber-100 text-amber-700",
|
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
||||||
overdue: "bg-red-100 text-red-700",
|
const [packages, setPackages] = useState<Package[]>([]);
|
||||||
};
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
export function BillingPayments({ readOnly }: Props) {
|
useEffect(() => {
|
||||||
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
|
async function fetchData() {
|
||||||
const [autopay, setAutopay] = useState(false);
|
if (!sessionId) {
|
||||||
const [showTipModal, setShowTipModal] = useState(false);
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const outstanding = INVOICES.filter(i => i.status === "outstanding");
|
try {
|
||||||
const totalOutstanding = outstanding.reduce((sum, i) => sum + i.amount, 0);
|
const response = await fetch("/api/portal/invoices", {
|
||||||
|
headers: {
|
||||||
|
"x-session-id": sessionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch invoices");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setInvoices(data.invoices || []);
|
||||||
|
setPaymentMethods(data.paymentMethods || []);
|
||||||
|
setPackages(data.packages || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "An error occurred");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const formatCents = (cents: number) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(cents / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-1/3"></div>
|
||||||
|
<div className="h-24 bg-gray-200 rounded"></div>
|
||||||
|
<div className="h-24 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="text-red-600">Error: {error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="p-6 space-y-8">
|
||||||
{/* Outstanding Balance Banner */}
|
<h2 className="text-2xl font-semibold">Billing & Payments</h2>
|
||||||
{totalOutstanding > 0 && (
|
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-stone-500">Outstanding Balance</p>
|
|
||||||
<p className="text-3xl font-bold text-stone-800">${totalOutstanding.toFixed(2)}</p>
|
|
||||||
<p className="text-xs text-stone-400 mt-0.5">{outstanding.length} unpaid invoice{outstanding.length > 1 ? "s" : ""}</p>
|
|
||||||
</div>
|
|
||||||
{!readOnly && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowTipModal(true)}
|
|
||||||
className="px-4 py-2 border border-stone-200 rounded-lg text-sm font-medium text-stone-600 hover:bg-stone-50"
|
|
||||||
>
|
|
||||||
Add Tip
|
|
||||||
</button>
|
|
||||||
<button className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)">
|
|
||||||
Pay Now
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{([
|
|
||||||
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
|
||||||
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
|
||||||
{ id: "packages" as const, label: "Packages", icon: Package },
|
|
||||||
]).map(({ id, label, icon: Icon }) => (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
onClick={() => setTab(id)}
|
|
||||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
|
|
||||||
tab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon size={14} />
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Invoices */}
|
|
||||||
{tab === "invoices" && (
|
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
|
|
||||||
<th className="px-5 py-3 font-medium">Date</th>
|
|
||||||
<th className="px-5 py-3 font-medium">Items</th>
|
|
||||||
<th className="px-5 py-3 font-medium">Amount</th>
|
|
||||||
<th className="px-5 py-3 font-medium">Status</th>
|
|
||||||
<th className="px-5 py-3 font-medium"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{INVOICES.map(inv => (
|
|
||||||
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
|
|
||||||
<td className="px-5 py-3 text-stone-700">
|
|
||||||
{new Date(inv.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3 text-stone-600">{inv.items.join(", ")}</td>
|
|
||||||
<td className="px-5 py-3 font-medium text-stone-800">${inv.amount.toFixed(2)}</td>
|
|
||||||
<td className="px-5 py-3">
|
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_STYLES[inv.status]}`}>
|
|
||||||
{inv.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3">
|
|
||||||
<button className="text-stone-400 hover:text-stone-600">
|
|
||||||
<Download size={14} />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Payment Methods */}
|
{/* Payment Methods */}
|
||||||
{tab === "payment" && (
|
<section>
|
||||||
<div className="space-y-4">
|
<h3 className="text-lg font-medium mb-4">Payment Methods</h3>
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm space-y-3">
|
{paymentMethods.length === 0 ? (
|
||||||
{SAVED_PAYMENT_METHODS.map(pm => (
|
<p className="text-gray-500 italic">No payment methods on file</p>
|
||||||
<div key={pm.id} className="flex items-center justify-between py-2 border-b border-stone-50 last:border-0">
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{paymentMethods.map((method) => (
|
||||||
|
<div
|
||||||
|
key={`${method.brand}-${method.last4}`}
|
||||||
|
className="flex items-center justify-between p-4 border rounded-lg"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-lg bg-stone-100 flex items-center justify-center">
|
<div className="w-10 h-6 bg-gray-200 rounded flex items-center justify-center text-xs">
|
||||||
<CreditCard size={18} className="text-stone-500" />
|
{method.brand.toUpperCase()}
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-stone-800 capitalize">{pm.type} •••• {pm.last4}</p>
|
|
||||||
<p className="text-xs text-stone-400">Expires {pm.expiry}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span>**** {method.last4}</span>
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{method.expiryMonth}/{method.expiryYear}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
{!readOnly && (
|
||||||
{pm.isDefault && (
|
<button className="text-sm text-blue-600 hover:underline">
|
||||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">Default</span>
|
Remove
|
||||||
)}
|
</button>
|
||||||
{!readOnly && (
|
)}
|
||||||
<button className="p-1 text-stone-400 hover:text-red-500">
|
</div>
|
||||||
<Trash2 size={14} />
|
))}
|
||||||
</button>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Packages */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-lg font-medium mb-4">Packages</h3>
|
||||||
|
{packages.length === 0 ? (
|
||||||
|
<p className="text-gray-500 italic">No packages purchased</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{packages.map((pkg, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between p-4 border rounded-lg"
|
||||||
|
>
|
||||||
|
<span>{pkg.name}</span>
|
||||||
|
<span className="text-gray-600">{pkg.remaining} remaining</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Invoices */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-lg font-medium mb-4">Invoice History</h3>
|
||||||
|
{invoices.length === 0 ? (
|
||||||
|
<p className="text-gray-500 italic">No invoices yet</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{invoices.map((invoice) => (
|
||||||
|
<div
|
||||||
|
key={invoice.id}
|
||||||
|
className="flex items-center justify-between p-4 border rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">
|
||||||
|
{invoice.description || `Invoice ${invoice.id.slice(0, 8)}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">{invoice.date}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{formatCents(invoice.totalCents)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded ${
|
||||||
|
invoice.status === "pending"
|
||||||
|
? "bg-yellow-100 text-yellow-800"
|
||||||
|
: "bg-green-100 text-green-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!readOnly && (
|
|
||||||
<button className="flex items-center gap-2 text-sm text-(--color-accent-dark) font-medium hover:underline mt-2">
|
|
||||||
<Plus size={14} />
|
|
||||||
Add Payment Method
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Autopay */}
|
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-(--color-accent-light) flex items-center justify-center">
|
|
||||||
<Zap size={18} className="text-(--color-accent)" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-stone-800">Autopay</p>
|
|
||||||
<p className="text-xs text-stone-500">Automatically charge after each appointment</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!readOnly ? (
|
|
||||||
<button
|
|
||||||
onClick={() => setAutopay(!autopay)}
|
|
||||||
className={`w-12 h-6 rounded-full transition-colors ${autopay ? "bg-(--color-accent)" : "bg-stone-300"}`}
|
|
||||||
>
|
|
||||||
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ${autopay ? "translate-x-6" : "translate-x-0.5"}`} />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-stone-400">{autopay ? "Enabled" : "Disabled"}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Packages */}
|
|
||||||
{tab === "packages" && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{PREPAID_PACKAGES.map(pkg => (
|
|
||||||
<div key={pkg.id} className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<Package size={20} className="text-(--color-accent)" />
|
|
||||||
<h3 className="font-medium text-stone-800">{pkg.name}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 mb-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-bold text-stone-800">{pkg.totalCredits - pkg.usedCredits}</p>
|
|
||||||
<p className="text-xs text-stone-500">remaining of {pkg.totalCredits}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 bg-stone-100 rounded-full h-3 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="bg-(--color-accent) h-full rounded-full"
|
|
||||||
style={{ width: `${((pkg.totalCredits - pkg.usedCredits) / pkg.totalCredits) * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-stone-400">Expires {new Date(pkg.expiresAt).toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tip Modal */}
|
|
||||||
{showTipModal && !readOnly && (
|
|
||||||
<TipModal onClose={() => setShowTipModal(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TipModal({ onClose }: { onClose: () => void }) {
|
|
||||||
const [tipPercent, setTipPercent] = useState<number | null>(20);
|
|
||||||
const [customTip, setCustomTip] = useState("");
|
|
||||||
const presets = [15, 20, 25];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="bg-white rounded-2xl shadow-xl max-w-sm w-full p-6">
|
|
||||||
<h2 className="font-semibold text-stone-800 mb-4">Add a Tip</h2>
|
|
||||||
<div className="flex gap-2 mb-4">
|
|
||||||
{presets.map(pct => (
|
|
||||||
<button
|
|
||||||
key={pct}
|
|
||||||
onClick={() => { setTipPercent(pct); setCustomTip(""); }}
|
|
||||||
className={`flex-1 py-2 rounded-lg text-sm font-medium border ${
|
|
||||||
tipPercent === pct ? "border-(--color-accent) bg-(--color-accent-lighter) text-(--color-accent-dark)" : "border-stone-200 text-stone-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pct}%
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={() => { setTipPercent(null); }}
|
|
||||||
className={`flex-1 py-2 rounded-lg text-sm font-medium border ${
|
|
||||||
tipPercent === null ? "border-(--color-accent) bg-(--color-accent-lighter) text-(--color-accent-dark)" : "border-stone-200 text-stone-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Custom
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{tipPercent === null && (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
placeholder="Enter amount"
|
|
||||||
value={customTip}
|
|
||||||
onChange={e => setCustomTip(e.target.value)}
|
|
||||||
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm mb-4"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
</section>
|
||||||
<button onClick={onClose} className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm">Cancel</button>
|
|
||||||
<button onClick={onClose} className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">Add Tip</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default BillingPayments;
|
||||||
@@ -1,7 +1,28 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Send, Check, CheckCheck, Bell, Mail, Smartphone, Megaphone, FileText, CreditCard } from "lucide-react";
|
import { Send, Check, CheckCheck, Bell, Mail, Smartphone, Megaphone, FileText, CreditCard } from "lucide-react";
|
||||||
import { MESSAGES, BUSINESS_NAME } from "../mockData.js";
|
|
||||||
import type { Message } from "../mockData.js";
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
sender: "customer" | "business";
|
||||||
|
senderName: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: string;
|
||||||
|
read: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationCategory {
|
||||||
|
email: boolean;
|
||||||
|
sms: boolean;
|
||||||
|
push: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationPreferences {
|
||||||
|
appointmentReminders: NotificationCategory;
|
||||||
|
vaccinationAlerts: NotificationCategory;
|
||||||
|
promotional: NotificationCategory;
|
||||||
|
reportCards: NotificationCategory;
|
||||||
|
invoiceReceipts: NotificationCategory;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
@@ -39,15 +60,31 @@ export function Communication({ readOnly }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MessageThread({ readOnly }: { readOnly: boolean }) {
|
function MessageThread({ readOnly }: { readOnly: boolean }) {
|
||||||
const [messages, setMessages] = useState<Message[]>(MESSAGES);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [newMessage, setNewMessage] = useState("");
|
const [newMessage, setNewMessage] = useState("");
|
||||||
|
const [businessName, setBusinessName] = useState<string>("Business");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchBranding() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/branding");
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setBusinessName(data.businessName || data.name || "Business");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setBusinessName("Business");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchBranding();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
if (!newMessage.trim() || readOnly) return;
|
if (!newMessage.trim() || readOnly) return;
|
||||||
const msg: Message = {
|
const msg: Message = {
|
||||||
id: `m-${Date.now()}`,
|
id: `m-${Date.now()}`,
|
||||||
sender: "customer",
|
sender: "customer",
|
||||||
senderName: "Sarah",
|
senderName: "You",
|
||||||
text: newMessage.trim(),
|
text: newMessage.trim(),
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
read: false,
|
read: false,
|
||||||
@@ -59,32 +96,36 @@ function MessageThread({ readOnly }: { readOnly: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
|
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
|
||||||
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50">
|
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50">
|
||||||
<p className="text-sm font-medium text-stone-800">{BUSINESS_NAME}</p>
|
<p className="text-sm font-medium text-stone-800">{businessName}</p>
|
||||||
<p className="text-xs text-stone-400">Usually replies within a few hours</p>
|
<p className="text-xs text-stone-400">Usually replies within a few hours</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
{messages.map(msg => (
|
{messages.length === 0 ? (
|
||||||
<div key={msg.id} className={`flex ${msg.sender === "customer" ? "justify-end" : "justify-start"}`}>
|
<p className="text-stone-400 text-center text-sm italic">No messages yet</p>
|
||||||
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
|
) : (
|
||||||
msg.sender === "customer"
|
messages.map(msg => (
|
||||||
? "bg-(--color-accent) text-white rounded-br-md"
|
<div key={msg.id} className={`flex ${msg.sender === "customer" ? "justify-end" : "justify-start"}`}>
|
||||||
: "bg-stone-100 text-stone-800 rounded-bl-md"
|
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
|
||||||
}`}>
|
msg.sender === "customer"
|
||||||
<p className="text-sm">{msg.text}</p>
|
? "bg-(--color-accent) text-white rounded-br-md"
|
||||||
<div className={`flex items-center gap-1 mt-1 ${msg.sender === "customer" ? "justify-end" : ""}`}>
|
: "bg-stone-100 text-stone-800 rounded-bl-md"
|
||||||
<span className={`text-xs ${msg.sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
|
}`}>
|
||||||
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
|
<p className="text-sm">{msg.text}</p>
|
||||||
</span>
|
<div className={`flex items-center gap-1 mt-1 ${msg.sender === "customer" ? "justify-end" : ""}`}>
|
||||||
{msg.sender === "customer" && (
|
<span className={`text-xs ${msg.sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
|
||||||
msg.read
|
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
|
||||||
? <CheckCheck size={12} className="text-white/60" />
|
</span>
|
||||||
: <Check size={12} className="text-white/60" />
|
{msg.sender === "customer" && (
|
||||||
)}
|
msg.read
|
||||||
|
? <CheckCheck size={12} className="text-white/60" />
|
||||||
|
: <Check size={12} className="text-white/60" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
@@ -111,7 +152,7 @@ function MessageThread({ readOnly }: { readOnly: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
|
function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
|
||||||
const [prefs, setPrefs] = useState({
|
const [prefs, setPrefs] = useState<NotificationPreferences>({
|
||||||
appointmentReminders: { email: true, sms: true, push: true },
|
appointmentReminders: { email: true, sms: true, push: true },
|
||||||
vaccinationAlerts: { email: true, sms: false, push: true },
|
vaccinationAlerts: { email: true, sms: false, push: true },
|
||||||
promotional: { email: false, sms: false, push: false },
|
promotional: { email: false, sms: false, push: false },
|
||||||
@@ -119,7 +160,7 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
|
|||||||
invoiceReceipts: { email: true, sms: false, push: false },
|
invoiceReceipts: { email: true, sms: false, push: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
type PrefKey = keyof typeof prefs;
|
type PrefKey = keyof NotificationPreferences;
|
||||||
type ChannelKey = "email" | "sms" | "push";
|
type ChannelKey = "email" | "sms" | "push";
|
||||||
|
|
||||||
const toggle = (category: PrefKey, channel: ChannelKey) => {
|
const toggle = (category: PrefKey, channel: ChannelKey) => {
|
||||||
@@ -194,3 +235,5 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Communication;
|
||||||
|
|||||||
@@ -1,11 +1,53 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
import { Calendar, Clock, PawPrint, CreditCard, Star, ChevronRight, AlertTriangle } from "lucide-react";
|
import { Calendar, Clock, PawPrint, CreditCard, Star, ChevronRight, AlertTriangle } from "lucide-react";
|
||||||
import { PETS, UPCOMING_APPOINTMENTS, PAST_APPOINTMENTS, INVOICES, LOYALTY, BUSINESS_NAME } from "../mockData.js";
|
|
||||||
|
|
||||||
interface Props {
|
interface DashboardProps {
|
||||||
|
sessionId: string | null;
|
||||||
|
clientName: string;
|
||||||
onNavigate: (section: "appointments" | "pets" | "billing" | "reports") => void;
|
onNavigate: (section: "appointments" | "pets" | "billing" | "reports") => void;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
onReschedule: (appointmentId: string) => void;
|
||||||
onReschedule?: (appointment: any) => void;
|
}
|
||||||
|
|
||||||
|
interface Appointment {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
petName: string;
|
||||||
|
serviceName: string;
|
||||||
|
status: string;
|
||||||
|
staffName?: string;
|
||||||
|
services?: string[];
|
||||||
|
addOns?: string[];
|
||||||
|
groomerName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pet {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
species: string;
|
||||||
|
breed?: string;
|
||||||
|
dateOfBirth?: string;
|
||||||
|
weight?: number;
|
||||||
|
healthAlerts: string[];
|
||||||
|
photo?: string;
|
||||||
|
vaccinations?: { name: string; status: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Invoice {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
date: string;
|
||||||
|
amount: number;
|
||||||
|
status: string;
|
||||||
|
dueDate?: string;
|
||||||
|
items: { description: string; price: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Branding {
|
||||||
|
clinicName: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
primaryColor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function daysUntil(dateStr: string): number {
|
function daysUntil(dateStr: string): number {
|
||||||
@@ -17,27 +59,154 @@ function daysUntil(dateStr: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
return new Date(dateStr).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Dashboard({ onNavigate, readOnly, onReschedule }: Props) {
|
export function Dashboard({
|
||||||
const nextAppt = UPCOMING_APPOINTMENTS[0];
|
sessionId,
|
||||||
const outstanding = INVOICES.filter(i => i.status === "outstanding").reduce((sum, i) => sum + i.amount, 0);
|
clientName,
|
||||||
const recentEvents = [
|
onNavigate,
|
||||||
...PAST_APPOINTMENTS.slice(0, 3).map(a => ({
|
readOnly,
|
||||||
id: a.id, date: a.date, text: `${a.petName} — ${a.services.join(", ")}`, type: "appointment" as const,
|
onReschedule,
|
||||||
})),
|
}: DashboardProps) {
|
||||||
...INVOICES.filter(i => i.status === "paid").slice(0, 2).map(i => ({
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
id: i.id, date: i.date, text: `Invoice paid — $${i.amount}`, type: "payment" as const,
|
const [pets, setPets] = useState<Pet[]>([]);
|
||||||
})),
|
const [pendingInvoices, setPendingInvoices] = useState<Invoice[]>([]);
|
||||||
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5);
|
const [branding, setBranding] = useState<Branding | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
if (!sessionId) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = {
|
||||||
|
"x-session-id": sessionId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [appointmentsRes, petsRes, invoicesRes, brandingRes] = await Promise.all([
|
||||||
|
fetch("/api/portal/appointments", { headers }),
|
||||||
|
fetch("/api/portal/pets", { headers }),
|
||||||
|
fetch("/api/portal/invoices", { headers }),
|
||||||
|
fetch("/api/branding", { headers }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!appointmentsRes.ok || !petsRes.ok || !invoicesRes.ok || !brandingRes.ok) {
|
||||||
|
throw new Error("Failed to fetch dashboard data");
|
||||||
|
}
|
||||||
|
|
||||||
|
const appointmentsData = await appointmentsRes.json();
|
||||||
|
const petsData = await petsRes.json();
|
||||||
|
const invoicesData = await invoicesRes.json();
|
||||||
|
const brandingData = await brandingRes.json();
|
||||||
|
|
||||||
|
setAppointments(appointmentsData.appointments || []);
|
||||||
|
setPets(petsData.pets || []);
|
||||||
|
|
||||||
|
// Filter for pending invoices only (not "outstanding")
|
||||||
|
const pending = (invoicesData.invoices || []).filter(
|
||||||
|
(invoice: Invoice) => invoice.status === "pending"
|
||||||
|
);
|
||||||
|
setPendingInvoices(pending);
|
||||||
|
|
||||||
|
setBranding(brandingData);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "An error occurred");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const getUpcomingAppointments = (): Appointment[] => {
|
||||||
|
const now = new Date();
|
||||||
|
return appointments
|
||||||
|
.filter((apt) => new Date(`${apt.date}T${apt.time}`) >= now)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(`${a.date}T${a.time}`).getTime() -
|
||||||
|
new Date(`${b.date}T${b.time}`).getTime()
|
||||||
|
)
|
||||||
|
.slice(0, 5);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPetHealthAlerts = (): { petName: string; alert: string }[] => {
|
||||||
|
return pets
|
||||||
|
.filter((pet) => pet.healthAlerts && pet.healthAlerts.length > 0)
|
||||||
|
.flatMap((pet) =>
|
||||||
|
pet.healthAlerts.map((alert) => ({ petName: pet.name, alert }))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number): string => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPendingBalance = (): number => {
|
||||||
|
return pendingInvoices.reduce((sum, invoice) => sum + invoice.amount, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-(--color-accent)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-2xl p-5">
|
||||||
|
<p className="text-red-700">Error: {error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-stone-100 rounded-2xl p-5 text-center">
|
||||||
|
<p className="text-stone-600">Please sign in to view your dashboard.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upcomingAppointments = getUpcomingAppointments();
|
||||||
|
const healthAlerts = getPetHealthAlerts();
|
||||||
|
const pendingBalance = getPendingBalance();
|
||||||
|
const nextAppt = upcomingAppointments[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Welcome */}
|
{/* Welcome */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold text-stone-800">Welcome back, Sarah</h2>
|
<h2 className="text-2xl font-semibold text-stone-800">
|
||||||
<p className="text-stone-500 text-sm mt-1">Here's what's happening at {BUSINESS_NAME}</p>
|
Welcome back, {clientName}
|
||||||
|
</h2>
|
||||||
|
<p className="text-stone-500 text-sm mt-1">
|
||||||
|
Here's what's happening at {branding?.clinicName || "your clinic"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Next Appointment */}
|
{/* Next Appointment */}
|
||||||
@@ -55,11 +224,16 @@ export function Dashboard({ onNavigate, readOnly, onReschedule }: Props) {
|
|||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-lg font-semibold text-stone-800">
|
<p className="text-lg font-semibold text-stone-800">
|
||||||
{nextAppt.petName} with {nextAppt.groomerName}
|
{nextAppt.petName}
|
||||||
|
{nextAppt.groomerName && ` with ${nextAppt.groomerName}`}
|
||||||
|
{nextAppt.staffName && ` with ${nextAppt.staffName}`}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-stone-600 text-sm mt-1">
|
<p className="text-stone-600 text-sm mt-1">
|
||||||
{nextAppt.services.join(", ")}
|
{nextAppt.services?.join(", ") ||
|
||||||
{nextAppt.addOns.length > 0 && ` + ${nextAppt.addOns.join(", ")}`}
|
nextAppt.serviceName ||
|
||||||
|
"Appointment"}
|
||||||
|
{nextAppt.addOns && nextAppt.addOns.length > 0 &&
|
||||||
|
` + ${nextAppt.addOns.join(", ")}`}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-4 mt-2 text-sm text-stone-500">
|
<div className="flex items-center gap-4 mt-2 text-sm text-stone-500">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
@@ -73,14 +247,16 @@ export function Dashboard({ onNavigate, readOnly, onReschedule }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center sm:text-right">
|
<div className="text-center sm:text-right">
|
||||||
<div className="text-3xl font-bold text-(--color-accent-dark)">{daysUntil(nextAppt.date)}</div>
|
<div className="text-3xl font-bold text-(--color-accent-dark)">
|
||||||
|
{daysUntil(nextAppt.date)}
|
||||||
|
</div>
|
||||||
<div className="text-xs text-stone-500">days away</div>
|
<div className="text-xs text-stone-500">days away</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div className="flex gap-2 mt-4">
|
<div className="flex gap-2 mt-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => onReschedule?.(nextAppt)}
|
onClick={() => onReschedule(nextAppt.id)}
|
||||||
className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50"
|
className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50"
|
||||||
>
|
>
|
||||||
Reschedule
|
Reschedule
|
||||||
@@ -99,8 +275,8 @@ export function Dashboard({ onNavigate, readOnly, onReschedule }: Props) {
|
|||||||
{/* Pet Cards & Loyalty */}
|
{/* Pet Cards & Loyalty */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{/* Pet Cards */}
|
{/* Pet Cards */}
|
||||||
{PETS.map(pet => {
|
{pets.map((pet) => {
|
||||||
const expiringVax = pet.vaccinations.filter(v => v.status !== "valid");
|
const petAlerts = pet.healthAlerts || [];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={pet.id}
|
key={pet.id}
|
||||||
@@ -109,59 +285,63 @@ export function Dashboard({ onNavigate, readOnly, onReschedule }: Props) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<div className="w-12 h-12 rounded-full bg-(--color-accent-light) flex items-center justify-center text-2xl">
|
<div className="w-12 h-12 rounded-full bg-(--color-accent-light) flex items-center justify-center text-2xl">
|
||||||
{pet.photo}
|
{pet.photo || pet.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-stone-800">{pet.name}</p>
|
<p className="font-semibold text-stone-800">{pet.name}</p>
|
||||||
<p className="text-xs text-stone-500">{pet.breed} · {pet.weight} lbs</p>
|
<p className="text-xs text-stone-500">
|
||||||
|
{pet.breed || pet.species}
|
||||||
|
{pet.weight && ` · ${pet.weight} lbs`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{expiringVax.length > 0 ? (
|
{petAlerts.length > 0 ? (
|
||||||
<div className="flex items-center gap-1.5 text-xs text-amber-700 bg-amber-50 px-2 py-1 rounded-lg">
|
<div className="flex items-center gap-1.5 text-xs text-amber-700 bg-amber-50 px-2 py-1 rounded-lg">
|
||||||
<AlertTriangle size={12} />
|
<AlertTriangle size={12} />
|
||||||
{expiringVax.map(v => v.name).join(", ")} {expiringVax[0]?.status === "expired" ? "expired" : "expiring soon"}
|
{petAlerts.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1.5 text-xs text-green-700 bg-green-50 px-2 py-1 rounded-lg">
|
<div className="flex items-center gap-1.5 text-xs text-green-700 bg-green-50 px-2 py-1 rounded-lg">
|
||||||
<PawPrint size={12} />
|
<PawPrint size={12} />
|
||||||
All vaccinations current
|
All health records current
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Loyalty Card */}
|
{/* Loyalty Card Placeholder */}
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm">
|
<div className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-(--color-accent-dark) mb-3">
|
<div className="flex items-center gap-2 text-sm font-medium text-(--color-accent-dark) mb-3">
|
||||||
<Star size={16} />
|
<Star size={16} />
|
||||||
Loyalty Rewards
|
Loyalty Rewards
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-stone-800">{LOYALTY.points} <span className="text-sm font-normal text-stone-500">pts</span></p>
|
<div className="flex flex-col items-center justify-center py-4">
|
||||||
<div className="mt-2 bg-stone-100 rounded-full h-2 overflow-hidden">
|
<div className="w-16 h-16 rounded-full bg-(--color-accent-light) flex items-center justify-center mb-3">
|
||||||
<div
|
<Star size={32} className="text-(--color-accent)" />
|
||||||
className="bg-(--color-accent) h-full rounded-full transition-all"
|
</div>
|
||||||
style={{ width: `${(LOYALTY.points / LOYALTY.nextRewardAt) * 100}%` }}
|
<p className="text-lg font-bold text-stone-800">Coming Soon</p>
|
||||||
/>
|
<p className="text-xs text-stone-500 text-center mt-1">
|
||||||
|
Earn points with every visit and redeem for exclusive rewards
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-stone-500 mt-1">
|
|
||||||
{LOYALTY.nextRewardAt - LOYALTY.points} pts to {LOYALTY.rewardName}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Outstanding Balance & Recent Activity */}
|
{/* Pending Balance & Recent Activity */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Outstanding Balance */}
|
{/* Pending Invoices */}
|
||||||
{outstanding > 0 && (
|
{pendingInvoices.length > 0 && (
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-stone-500 mb-1">
|
<div className="flex items-center gap-2 text-sm font-medium text-stone-500 mb-1">
|
||||||
<CreditCard size={16} />
|
<CreditCard size={16} />
|
||||||
Outstanding Balance
|
Pending Invoices
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-stone-800">${outstanding.toFixed(2)}</p>
|
<p className="text-2xl font-bold text-stone-800">
|
||||||
|
{formatCurrency(pendingBalance)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
@@ -172,29 +352,51 @@ export function Dashboard({ onNavigate, readOnly, onReschedule }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pendingInvoices.slice(0, 3).map((invoice) => (
|
||||||
|
<div
|
||||||
|
key={invoice.id}
|
||||||
|
className="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-stone-600">
|
||||||
|
{invoice.invoiceNumber} - {formatCurrency(invoice.amount)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-stone-400">
|
||||||
|
Due {invoice.dueDate ? formatDate(invoice.dueDate) : formatDate(invoice.date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recent Activity */}
|
{/* Health Alerts */}
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
{healthAlerts.length > 0 && (
|
||||||
<h3 className="text-sm font-medium text-stone-500 mb-3">Recent Activity</h3>
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
<div className="space-y-2.5">
|
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 mb-3">
|
||||||
{recentEvents.map(evt => (
|
<AlertTriangle size={16} />
|
||||||
<div key={evt.id} className="flex items-center gap-3 text-sm">
|
Health Alerts
|
||||||
<div className={`w-2 h-2 rounded-full shrink-0 ${evt.type === "payment" ? "bg-green-400" : "bg-(--color-accent)"}`} />
|
</div>
|
||||||
<span className="text-stone-600 flex-1">{evt.text}</span>
|
<div className="space-y-2">
|
||||||
<span className="text-xs text-stone-400">{formatDate(evt.date)}</span>
|
{healthAlerts.slice(0, 5).map((item, index) => (
|
||||||
</div>
|
<div key={index} className="flex items-center gap-3 text-sm">
|
||||||
))}
|
<div className="w-2 h-2 rounded-full shrink-0 bg-amber-400" />
|
||||||
|
<span className="text-stone-600 flex-1">
|
||||||
|
<span className="font-medium">{item.petName}:</span>{" "}
|
||||||
|
{item.alert}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate("pets")}
|
||||||
|
className="flex items-center gap-1 text-sm text-(--color-accent-dark) font-medium mt-3 hover:text-(--color-accent)"
|
||||||
|
>
|
||||||
|
View all <ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
)}
|
||||||
onClick={() => onNavigate("appointments")}
|
|
||||||
className="flex items-center gap-1 text-sm text-(--color-accent-dark) font-medium mt-3 hover:text-(--color-accent)"
|
|
||||||
>
|
|
||||||
View all <ChevronRight size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,52 +1,152 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { PawPrint, Heart, Scissors, Syringe, AlertTriangle, CheckCircle, Clock, Upload, Edit3 } from "lucide-react";
|
import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2 } from "lucide-react";
|
||||||
import { PETS, PAST_APPOINTMENTS } from "../mockData.js";
|
|
||||||
import type { Pet } from "../mockData.js";
|
|
||||||
import { PetForm } from "./PetForm.js";
|
import { PetForm } from "./PetForm.js";
|
||||||
|
|
||||||
|
interface Pet {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
breed: string;
|
||||||
|
weight: number;
|
||||||
|
birthDate: string;
|
||||||
|
photoUrl: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Appointment {
|
||||||
|
id: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
status: string;
|
||||||
|
confirmationStatus: string | null;
|
||||||
|
customerNotes: string | null;
|
||||||
|
groomerNotes: string | null;
|
||||||
|
reportCardId: string | null;
|
||||||
|
pet: { id: string; name: string; photo: string | null } | null;
|
||||||
|
service: { id: string } | null;
|
||||||
|
staff: { id: string; name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppointmentsResponse {
|
||||||
|
upcoming: Appointment[];
|
||||||
|
past: Appointment[];
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
sessionId: string | null;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type VaxStatus = "valid" | "expiring" | "expired";
|
function buildHeaders(sessionId: string | null): Record<string, string> {
|
||||||
const VAX_STATUS_STYLES: Record<VaxStatus, { bg: string; text: string; icon: typeof CheckCircle }> = {
|
const headers: Record<string, string> = {};
|
||||||
valid: { bg: "bg-green-100", text: "text-green-700", icon: CheckCircle },
|
if (sessionId) {
|
||||||
expiring: { bg: "bg-amber-100", text: "text-amber-700", icon: Clock },
|
headers["X-Impersonation-Session-Id"] = sessionId;
|
||||||
expired: { bg: "bg-red-100", text: "text-red-700", icon: AlertTriangle },
|
}
|
||||||
};
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
export function PetProfiles({ readOnly }: Props) {
|
export function PetProfiles({ sessionId, readOnly }: Props) {
|
||||||
const [selectedPetId, setSelectedPetId] = useState<string>(PETS[0]?.id ?? "");
|
const [pets, setPets] = useState<Pet[]>([]);
|
||||||
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "vaccinations" | "history">("info");
|
const [appointments, setAppointments] = useState<AppointmentsResponse>({ upcoming: [], past: [] });
|
||||||
|
const [selectedPetId, setSelectedPetId] = useState<string>("");
|
||||||
|
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
|
||||||
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const pet = PETS.find(p => p.id === selectedPetId)!;
|
useEffect(() => {
|
||||||
const petHistory = PAST_APPOINTMENTS.filter(a => a.petId === selectedPetId);
|
async function fetchData() {
|
||||||
const editingPet = editingPetId ? PETS.find(p => p.id === editingPetId) ?? undefined : undefined;
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [petsRes, apptsRes] = await Promise.all([
|
||||||
|
fetch("/api/portal/pets", { headers: buildHeaders(sessionId) }),
|
||||||
|
fetch("/api/portal/appointments", { headers: buildHeaders(sessionId) }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!petsRes.ok) {
|
||||||
|
throw new Error("Failed to load pets");
|
||||||
|
}
|
||||||
|
if (!apptsRes.ok) {
|
||||||
|
throw new Error("Failed to load appointments");
|
||||||
|
}
|
||||||
|
|
||||||
|
const petsData = await petsRes.json();
|
||||||
|
const apptsData: AppointmentsResponse = await apptsRes.json();
|
||||||
|
|
||||||
|
setPets(petsData);
|
||||||
|
setAppointments(apptsData);
|
||||||
|
|
||||||
|
if (petsData.length > 0 && !selectedPetId) {
|
||||||
|
setSelectedPetId(petsData[0].id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load data");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
|
||||||
|
const petHistory = appointments.past.filter(a => a.pet?.id === selectedPetId);
|
||||||
|
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
|
||||||
|
|
||||||
|
function handlePetSave(updatedPet: Pet) {
|
||||||
|
setPets(prev => prev.map(p => p.id === updatedPet.id ? updatedPet : p));
|
||||||
|
setEditingPetId(null);
|
||||||
|
}
|
||||||
|
|
||||||
if (editingPet) {
|
if (editingPet) {
|
||||||
return (
|
return (
|
||||||
<PetForm
|
<PetForm
|
||||||
pet={editingPet}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
onSave={() => setEditingPetId(null)}
|
pet={editingPet as any}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onSave={handlePetSave as any}
|
||||||
onCancel={() => setEditingPetId(null)}
|
onCancel={() => setEditingPetId(null)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 size={24} className="animate-spin text-stone-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-red-500 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pets.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-stone-400 text-sm">No pets found</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Pet Selector */}
|
{/* Pet Selector */}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3 overflow-x-auto pb-1">
|
||||||
{PETS.map(p => (
|
{pets.map(p => (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() => { setSelectedPetId(p.id); setActiveTab("info"); }}
|
onClick={() => { setSelectedPetId(p.id); setActiveTab("info"); }}
|
||||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors ${
|
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors shrink-0 ${
|
||||||
p.id === selectedPetId ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 bg-white hover:border-stone-300"
|
p.id === selectedPetId ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 bg-white hover:border-stone-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-2xl">{p.photo}</span>
|
<span className="text-2xl">{p.photoUrl ? "🐾" : "🐾"}</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="font-medium text-stone-800 text-sm">{p.name}</p>
|
<p className="font-medium text-stone-800 text-sm">{p.name}</p>
|
||||||
<p className="text-xs text-stone-500">{p.breed}</p>
|
<p className="text-xs text-stone-500">{p.breed}</p>
|
||||||
@@ -56,23 +156,31 @@ export function PetProfiles({ readOnly }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Profile Header */}
|
{/* Profile Header */}
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
{selectedPet && (
|
||||||
<div className="flex items-center gap-4">
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
<div className="w-20 h-20 rounded-2xl bg-(--color-accent-light) flex items-center justify-center text-4xl">
|
<div className="flex items-center gap-4">
|
||||||
{pet.photo}
|
<div className="w-20 h-20 rounded-2xl bg-(--color-accent-light) flex items-center justify-center text-4xl overflow-hidden">
|
||||||
|
{selectedPet.photoUrl ? (
|
||||||
|
<img src={selectedPet.photoUrl} alt={selectedPet.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span>🐾</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-semibold text-stone-800">{selectedPet.name}</h2>
|
||||||
|
<p className="text-stone-500 text-sm">{selectedPet.breed} · {selectedPet.weight} lbs</p>
|
||||||
|
<p className="text-stone-400 text-xs mt-0.5">
|
||||||
|
Born {selectedPet.birthDate ? new Date(selectedPet.birthDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!readOnly && (
|
||||||
|
<button onClick={() => setEditingPetId(selectedPet.id)} className="p-2 hover:bg-stone-50 rounded-lg">
|
||||||
|
<Edit3 size={16} className="text-stone-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
|
||||||
<h2 className="text-xl font-semibold text-stone-800">{pet.name}</h2>
|
|
||||||
<p className="text-stone-500 text-sm">{pet.breed} · {pet.weight} lbs · {pet.sex === "male" ? "♂" : "♀"} {pet.spayedNeutered ? "(spayed/neutered)" : ""}</p>
|
|
||||||
<p className="text-stone-400 text-xs mt-0.5">Born {new Date(pet.dob).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}</p>
|
|
||||||
</div>
|
|
||||||
{!readOnly && (
|
|
||||||
<button onClick={() => setEditingPetId(pet.id)} className="p-2 hover:bg-stone-50 rounded-lg">
|
|
||||||
<Edit3 size={16} className="text-stone-400" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto">
|
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto">
|
||||||
@@ -80,7 +188,6 @@ export function PetProfiles({ readOnly }: Props) {
|
|||||||
{ id: "info", label: "Basic Info", icon: PawPrint },
|
{ id: "info", label: "Basic Info", icon: PawPrint },
|
||||||
{ id: "medical", label: "Medical", icon: Heart },
|
{ id: "medical", label: "Medical", icon: Heart },
|
||||||
{ id: "grooming", label: "Grooming", icon: Scissors },
|
{ id: "grooming", label: "Grooming", icon: Scissors },
|
||||||
{ id: "vaccinations", label: "Vaccinations", icon: Syringe },
|
|
||||||
{ id: "history", label: "History", icon: Clock },
|
{ id: "history", label: "History", icon: Clock },
|
||||||
] as const).map(({ id, label, icon: Icon }) => (
|
] as const).map(({ id, label, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
@@ -98,10 +205,9 @@ export function PetProfiles({ readOnly }: Props) {
|
|||||||
|
|
||||||
{/* Tab Content */}
|
{/* Tab Content */}
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
{activeTab === "info" && <BasicInfoTab pet={pet} readOnly={readOnly} />}
|
{activeTab === "info" && selectedPet && <BasicInfoTab pet={selectedPet} readOnly={readOnly} />}
|
||||||
{activeTab === "medical" && <MedicalTab pet={pet} readOnly={readOnly} />}
|
{activeTab === "medical" && selectedPet && <MedicalTab pet={selectedPet} readOnly={readOnly} />}
|
||||||
{activeTab === "grooming" && <GroomingTab pet={pet} readOnly={readOnly} />}
|
{activeTab === "grooming" && selectedPet && <GroomingTab pet={selectedPet} readOnly={readOnly} />}
|
||||||
{activeTab === "vaccinations" && <VaccinationsTab pet={pet} readOnly={readOnly} />}
|
|
||||||
{activeTab === "history" && <HistoryTab petHistory={petHistory} />}
|
{activeTab === "history" && <HistoryTab petHistory={petHistory} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,11 +227,10 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<InfoRow label="Name" value={pet.name} />
|
<InfoRow label="Name" value={pet.name} />
|
||||||
<InfoRow label="Breed" value={pet.breed} />
|
<InfoRow label="Breed" value={pet.breed || "Unknown"} />
|
||||||
<InfoRow label="Weight" value={`${pet.weight} lbs`} />
|
<InfoRow label="Weight" value={`${pet.weight} lbs`} />
|
||||||
<InfoRow label="Date of Birth" value={new Date(pet.dob).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })} />
|
<InfoRow label="Date of Birth" value={pet.birthDate ? new Date(pet.birthDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"} />
|
||||||
<InfoRow label="Sex" value={pet.sex === "male" ? "Male" : "Female"} />
|
<InfoRow label="Notes" value={pet.notes || "None"} />
|
||||||
<InfoRow label="Spayed/Neutered" value={pet.spayedNeutered ? "Yes" : "No"} />
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
|
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
|
||||||
Upload Photo
|
Upload Photo
|
||||||
@@ -138,12 +243,7 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
|||||||
function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<InfoRow label="Allergies" value={pet.allergies} />
|
<InfoRow label="Notes" value={pet.notes || "No medical notes on file"} />
|
||||||
<InfoRow label="Skin Conditions" value={pet.skinConditions} />
|
|
||||||
<InfoRow label="Anxiety Triggers" value={pet.anxietyTriggers} />
|
|
||||||
<InfoRow label="Aggression Notes" value={pet.aggressionNotes} />
|
|
||||||
<InfoRow label="Mobility Issues" value={pet.mobilityIssues} />
|
|
||||||
<InfoRow label="Medications" value={pet.medications} />
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<p className="mt-3 text-xs text-stone-400">
|
<p className="mt-3 text-xs text-stone-400">
|
||||||
Changes to medical notes will be flagged for staff review.
|
Changes to medical notes will be flagged for staff review.
|
||||||
@@ -156,10 +256,7 @@ function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
|||||||
function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<InfoRow label="Preferred Cut" value={pet.preferredCut} />
|
<InfoRow label="Notes" value={pet.notes || "No grooming notes on file"} />
|
||||||
<InfoRow label="Shampoo Preference" value={pet.shampooPreference} />
|
|
||||||
<InfoRow label="Sensitive Areas" value={pet.sensitiveAreas} />
|
|
||||||
<InfoRow label="Standing Instructions" value={pet.standingInstructions} />
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
|
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
|
||||||
Upload Reference Photo
|
Upload Reference Photo
|
||||||
@@ -169,58 +266,7 @@ function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VaccinationsTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
function HistoryTab({ petHistory }: { petHistory: Appointment[] }) {
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
|
|
||||||
<th className="pb-2 font-medium">Vaccine</th>
|
|
||||||
<th className="pb-2 font-medium">Administered</th>
|
|
||||||
<th className="pb-2 font-medium">Expires</th>
|
|
||||||
<th className="pb-2 font-medium">Status</th>
|
|
||||||
<th className="pb-2 font-medium">Proof</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{pet.vaccinations.map(vax => {
|
|
||||||
const style = VAX_STATUS_STYLES[vax.status];
|
|
||||||
const StatusIcon = style.icon;
|
|
||||||
return (
|
|
||||||
<tr key={vax.name} className="border-b border-stone-50">
|
|
||||||
<td className="py-2.5 font-medium text-stone-800">{vax.name}</td>
|
|
||||||
<td className="py-2.5 text-stone-600">{new Date(vax.lastAdministered).toLocaleDateString()}</td>
|
|
||||||
<td className="py-2.5 text-stone-600">{new Date(vax.expirationDate).toLocaleDateString()}</td>
|
|
||||||
<td className="py-2.5">
|
|
||||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${style.bg} ${style.text}`}>
|
|
||||||
<StatusIcon size={12} />
|
|
||||||
{vax.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-2.5">
|
|
||||||
{vax.documentUploaded ? (
|
|
||||||
<span className="text-green-600 text-xs">Uploaded</span>
|
|
||||||
) : !readOnly ? (
|
|
||||||
<button className="flex items-center gap-1 text-xs text-(--color-accent-dark) hover:underline">
|
|
||||||
<Upload size={12} />
|
|
||||||
Upload
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="text-stone-400 text-xs">Missing</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HistoryTab({ petHistory }: { petHistory: typeof PAST_APPOINTMENTS }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{petHistory.length === 0 ? (
|
{petHistory.length === 0 ? (
|
||||||
@@ -232,14 +278,18 @@ function HistoryTab({ petHistory }: { petHistory: typeof PAST_APPOINTMENTS }) {
|
|||||||
<Scissors size={14} />
|
<Scissors size={14} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-stone-800">{appt.services.join(", ")}</p>
|
<p className="text-sm font-medium text-stone-800">
|
||||||
<p className="text-xs text-stone-500">with {appt.groomerName} · ${appt.price}</p>
|
{appt.service ? "Grooming Service" : "Appointment"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-stone-500">
|
||||||
|
with {appt.staff?.name || "Unknown Groomer"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-stone-400">
|
<span className="text-xs text-stone-400">
|
||||||
{new Date(appt.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
{new Date(appt.startTime).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
||||||
</span>
|
</span>
|
||||||
{appt.reportCardId && (
|
{appt.reportCardId && (
|
||||||
<span className="text-xs text-(--color-accent-dark) font-medium">Report →</span>
|
<span className="text-xs text-(--color-accent-dark) font-medium">Report</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { FileText, Share2, Calendar, Smile, Meh, AlertCircle, ChevronRight } from "lucide-react";
|
import { FileText, Share2, Calendar, Smile, Meh, ChevronRight, Loader2 } from "lucide-react";
|
||||||
import { REPORT_CARDS } from "../mockData.js";
|
|
||||||
import type { ReportCard } from "../mockData.js";
|
|
||||||
|
|
||||||
type MoodKey = "calm" | "cooperative" | "anxious" | "wiggly";
|
type MoodKey = "calm" | "cooperative" | "anxious" | "wiggly";
|
||||||
|
|
||||||
const MOOD_CONFIG: Record<MoodKey, { icon: typeof Smile; label: string; color: string; bg: string }> = {
|
const MOOD_CONFIG: Record<MoodKey, { icon: typeof Smile; label: string; color: string; bg: string }> = {
|
||||||
calm: { icon: Smile, label: "Calm & Relaxed", color: "text-green-700", bg: "bg-green-100" },
|
calm: { icon: Smile, label: "Calm & Relaxed", color: "text-green-700", bg: "bg-green-100" },
|
||||||
cooperative: { icon: Smile, label: "Cooperative", color: "text-blue-700", bg: "bg-blue-100" },
|
cooperative: { icon: Smile, label: "Cooperative", color: "text-blue-700", bg: "bg-blue-100" },
|
||||||
@@ -11,8 +10,87 @@ const MOOD_CONFIG: Record<MoodKey, { icon: typeof Smile; label: string; color: s
|
|||||||
wiggly: { icon: Meh, label: "Wiggly", color: "text-purple-700", bg: "bg-purple-100" },
|
wiggly: { icon: Meh, label: "Wiggly", color: "text-purple-700", bg: "bg-purple-100" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface Appointment {
|
||||||
|
id: string;
|
||||||
|
petId: string;
|
||||||
|
serviceId: string;
|
||||||
|
groomerId: string | null;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
status: string;
|
||||||
|
petName?: string;
|
||||||
|
serviceName?: string;
|
||||||
|
groomerName?: string;
|
||||||
|
reportCardId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function ReportCards() {
|
export function ReportCards() {
|
||||||
const [selectedCard, setSelectedCard] = useState<ReportCard | null>(null);
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedCard, setSelectedCard] = useState<Appointment | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchReportCards = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/portal/appointments");
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const allAppointments: Appointment[] = data.appointments || data || [];
|
||||||
|
const reportCardAppointments = allAppointments.filter(
|
||||||
|
(appt) => appt.reportCardId
|
||||||
|
);
|
||||||
|
setAppointments(reportCardAppointments);
|
||||||
|
} else {
|
||||||
|
setError("Failed to load report cards.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load report cards. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchReportCards();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="animate-spin text-stone-400" size={24} />
|
||||||
|
<span className="ml-3 text-stone-500">Loading report cards...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-red-600 mb-4">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="px-4 py-2 bg-stone-100 text-stone-700 rounded-md hover:bg-stone-200"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appointments.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-stone-100 flex items-center justify-center">
|
||||||
|
<FileText size={24} className="text-stone-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-stone-800 mb-1">No Report Cards Yet</h3>
|
||||||
|
<p className="text-sm text-stone-500">
|
||||||
|
Report cards from your grooming visits will appear here after your appointments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedCard) {
|
if (selectedCard) {
|
||||||
return <ReportCardDetail card={selectedCard} onBack={() => setSelectedCard(null)} />;
|
return <ReportCardDetail card={selectedCard} onBack={() => setSelectedCard(null)} />;
|
||||||
@@ -23,8 +101,9 @@ export function ReportCards() {
|
|||||||
<p className="text-sm text-stone-500">Grooming report cards from your recent visits</p>
|
<p className="text-sm text-stone-500">Grooming report cards from your recent visits</p>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{REPORT_CARDS.map(card => {
|
{appointments.map((card) => {
|
||||||
const mood = MOOD_CONFIG[card.behaviorMood];
|
const moodKey: MoodKey = "cooperative";
|
||||||
|
const mood = MOOD_CONFIG[moodKey];
|
||||||
const MoodIcon = mood.icon;
|
const MoodIcon = mood.icon;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -38,16 +117,20 @@ export function ReportCards() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="font-semibold text-stone-800">{card.petName}'s Report Card</h3>
|
<h3 className="font-semibold text-stone-800">{card.petName || "Pet"}'s Report Card</h3>
|
||||||
<ChevronRight size={16} className="text-stone-400" />
|
<ChevronRight size={16} className="text-stone-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-stone-500 mt-0.5">
|
<p className="text-sm text-stone-500 mt-0.5">
|
||||||
{card.servicesPerformed.join(", ")} with {card.groomerName}
|
{card.serviceName || "Grooming"} with {card.groomerName || "your groomer"}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-3 mt-2">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
<span className="flex items-center gap-1 text-xs text-stone-400">
|
<span className="flex items-center gap-1 text-xs text-stone-400">
|
||||||
<Calendar size={12} />
|
<Calendar size={12} />
|
||||||
{new Date(card.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
{new Date(card.date).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full ${mood.bg} ${mood.color}`}>
|
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full ${mood.bg} ${mood.color}`}>
|
||||||
<MoodIcon size={12} />
|
<MoodIcon size={12} />
|
||||||
@@ -64,28 +147,40 @@ export function ReportCards() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => void }) {
|
function ReportCardDetail({ card, onBack }: { card: Appointment; onBack: () => void }) {
|
||||||
const mood = MOOD_CONFIG[card.behaviorMood];
|
const moodKey: MoodKey = "cooperative";
|
||||||
|
const mood = MOOD_CONFIG[moodKey];
|
||||||
const MoodIcon = mood.icon;
|
const MoodIcon = mood.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<button onClick={onBack} className="text-sm text-(--color-accent-dark) font-medium hover:underline">
|
<button
|
||||||
← Back to Report Cards
|
onClick={onBack}
|
||||||
|
className="text-sm text-(--color-accent-dark) font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Back to Report Cards
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-gradient-to-r from-(--color-accent-lighter) to-(--color-accent-light) p-6">
|
<div className="bg-gradient-to-r from-(--color-accent-lighter) to-(--color-accent-light) p-6">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<h2 className="text-xl font-semibold text-stone-800">{card.petName}'s Grooming Report</h2>
|
<h2 className="text-xl font-semibold text-stone-800">
|
||||||
|
{card.petName || "Pet"}'s Grooming Report
|
||||||
|
</h2>
|
||||||
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-white/80 text-stone-700 rounded-lg text-sm font-medium hover:bg-white">
|
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-white/80 text-stone-700 rounded-lg text-sm font-medium hover:bg-white">
|
||||||
<Share2 size={14} />
|
<Share2 size={14} />
|
||||||
Share
|
Share
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-stone-600">
|
<p className="text-sm text-stone-600">
|
||||||
{new Date(card.date).toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })} · Groomer: {card.groomerName}
|
{new Date(card.date).toLocaleDateString("en-US", {
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
{card.groomerName ? ` · Groomer: ${card.groomerName}` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,14 +194,14 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
|
|||||||
<div className="w-full h-32 bg-stone-200 rounded-lg flex items-center justify-center text-stone-400 text-sm mb-2">
|
<div className="w-full h-32 bg-stone-200 rounded-lg flex items-center justify-center text-stone-400 text-sm mb-2">
|
||||||
Photo placeholder
|
Photo placeholder
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-stone-600">{card.beforeDescription}</p>
|
<p className="text-sm text-stone-600">Before photo description not available.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-(--color-accent-lighter) p-4">
|
<div className="rounded-xl bg-(--color-accent-lighter) p-4">
|
||||||
<p className="text-xs font-medium text-(--color-accent) uppercase mb-2">After</p>
|
<p className="text-xs font-medium text-(--color-accent) uppercase mb-2">After</p>
|
||||||
<div className="w-full h-32 bg-(--color-accent-light) rounded-lg flex items-center justify-center text-(--color-accent) text-sm mb-2">
|
<div className="w-full h-32 bg-(--color-accent-light) rounded-lg flex items-center justify-center text-(--color-accent) text-sm mb-2">
|
||||||
Photo placeholder
|
Photo placeholder
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-stone-700">{card.afterDescription}</p>
|
<p className="text-sm text-stone-700">After photo description not available.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,11 +210,9 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-stone-800 mb-2">Services Performed</h3>
|
<h3 className="font-medium text-stone-800 mb-2">Services Performed</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{card.servicesPerformed.map(s => (
|
<span className="px-3 py-1 bg-stone-100 rounded-full text-sm text-stone-700">
|
||||||
<span key={s} className="px-3 py-1 bg-stone-100 rounded-full text-sm text-stone-700">
|
{card.serviceName || "Grooming"}
|
||||||
{s}
|
</span>
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -132,37 +225,24 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Condition Observations */}
|
|
||||||
{card.conditionObservations.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-stone-800 mb-2">Condition Observations</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{card.conditionObservations.map((obs, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-2 text-sm">
|
|
||||||
<AlertCircle size={16} className="text-amber-500 mt-0.5 shrink-0" />
|
|
||||||
<span className="text-stone-700">{obs}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Groomer's Note */}
|
{/* Groomer's Note */}
|
||||||
<div className="bg-(--color-accent-lighter) rounded-xl p-4">
|
<div className="bg-(--color-accent-lighter) rounded-xl p-4">
|
||||||
<h3 className="font-medium text-stone-800 mb-2">A Note from {card.groomerName}</h3>
|
<h3 className="font-medium text-stone-800 mb-2">
|
||||||
<p className="text-sm text-stone-700 italic leading-relaxed">"{card.groomerNote}"</p>
|
A Note from {card.groomerName || "Your Groomer"}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-stone-700 italic leading-relaxed">
|
||||||
|
"Report card details are not yet available. Please check back after your visit."
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Next Appointment CTA */}
|
{/* Next Appointment CTA */}
|
||||||
<div className="bg-white border border-stone-200 rounded-xl p-4 flex items-center justify-between">
|
<div className="bg-white border border-stone-200 rounded-xl p-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-stone-800">Next recommended visit</p>
|
<p className="text-sm font-medium text-stone-800">Book your next visit</p>
|
||||||
<p className="text-xs text-stone-500">
|
<p className="text-xs text-stone-500">Schedule your next grooming appointment</p>
|
||||||
{new Date(card.nextRecommendedDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)">
|
<button className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)">
|
||||||
Rebook Now
|
Book Now
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +1 @@
|
|||||||
CREATE TYPE "public"."waitlist_status" AS ENUM('active', 'notified', 'expired', 'cancelled');--> statement-breakpoint
|
ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL;
|
||||||
CREATE TABLE "account" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"account_id" text NOT NULL,
|
|
||||||
"provider_id" text NOT NULL,
|
|
||||||
"user_id" text NOT NULL,
|
|
||||||
"access_token" text,
|
|
||||||
"refresh_token" text,
|
|
||||||
"id_token" text,
|
|
||||||
"access_token_expires_at" timestamp,
|
|
||||||
"refresh_token_expires_at" timestamp,
|
|
||||||
"scope" text,
|
|
||||||
"password" text,
|
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "session" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"expires_at" timestamp NOT NULL,
|
|
||||||
"token" text NOT NULL,
|
|
||||||
"ip_address" text,
|
|
||||||
"user_agent" text,
|
|
||||||
"user_id" text NOT NULL,
|
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "session_token_unique" UNIQUE("token")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "user" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"email" text NOT NULL,
|
|
||||||
"email_verified" boolean DEFAULT false NOT NULL,
|
|
||||||
"image" text,
|
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "user_email_unique" UNIQUE("email")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "verification" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"identifier" text NOT NULL,
|
|
||||||
"value" text NOT NULL,
|
|
||||||
"expires_at" timestamp NOT NULL,
|
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "waitlist_entries" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"client_id" uuid NOT NULL,
|
|
||||||
"pet_id" uuid NOT NULL,
|
|
||||||
"service_id" uuid NOT NULL,
|
|
||||||
"preferred_date" text NOT NULL,
|
|
||||||
"preferred_time" text NOT NULL,
|
|
||||||
"status" "waitlist_status" DEFAULT 'active' NOT NULL,
|
|
||||||
"notified_at" timestamp,
|
|
||||||
"expires_at" timestamp,
|
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "appointments" ADD COLUMN "confirmation_status" text DEFAULT 'pending' NOT NULL;--> statement-breakpoint
|
|
||||||
ALTER TABLE "appointments" ADD COLUMN "confirmed_at" timestamp;--> statement-breakpoint
|
|
||||||
ALTER TABLE "appointments" ADD COLUMN "cancelled_at" timestamp;--> statement-breakpoint
|
|
||||||
ALTER TABLE "appointments" ADD COLUMN "confirmation_token" text;--> statement-breakpoint
|
|
||||||
ALTER TABLE "appointments" ADD COLUMN "customer_notes" text;--> statement-breakpoint
|
|
||||||
ALTER TABLE "pets" ADD COLUMN "photo_key" text;--> statement-breakpoint
|
|
||||||
ALTER TABLE "pets" ADD COLUMN "photo_uploaded_at" timestamp;--> statement-breakpoint
|
|
||||||
ALTER TABLE "staff" ADD COLUMN "user_id" text;--> statement-breakpoint
|
|
||||||
ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
|
||||||
ALTER TABLE "staff" ADD COLUMN "ical_token" text;--> statement-breakpoint
|
|
||||||
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_service_id_services_id_fk" FOREIGN KEY ("service_id") REFERENCES "public"."services"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
CREATE INDEX "idx_waitlist_client_id" ON "waitlist_entries" USING btree ("client_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "idx_waitlist_preferred_date" ON "waitlist_entries" USING btree ("preferred_date");--> statement-breakpoint
|
|
||||||
CREATE INDEX "idx_waitlist_status" ON "waitlist_entries" USING btree ("status");--> statement-breakpoint
|
|
||||||
ALTER TABLE "staff" ADD CONSTRAINT "staff_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_confirmation_token_unique" UNIQUE("confirmation_token");--> statement-breakpoint
|
|
||||||
ALTER TABLE "staff" ADD CONSTRAINT "staff_ical_token_unique" UNIQUE("ical_token");
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,13 @@
|
|||||||
"when": 1774598400000,
|
"when": 1774598400000,
|
||||||
"tag": "0018_backfill_staff_user_id",
|
"tag": "0018_backfill_staff_user_id",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774729055924,
|
||||||
|
"tag": "0019_concerned_sunfire",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ import postgres from "postgres";
|
|||||||
import * as schema from "./schema.js";
|
import * as schema from "./schema.js";
|
||||||
|
|
||||||
export * from "./schema.js";
|
export * from "./schema.js";
|
||||||
export { and, asc, desc, eq, exists, gte, gt, ilike, lt, lte, ne, or, sql } from "drizzle-orm";
|
export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, lt, lte, ne, or, sql } from "drizzle-orm";
|
||||||
|
|
||||||
let _db: ReturnType<typeof drizzle> | null = null;
|
let _db: ReturnType<typeof drizzle> | null = null;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user