Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc0259975b |
@@ -78,13 +78,6 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR
|
||||
| TC-APP-4.5.4 | Calendar view (day/week/month) | 1. Navigate to Calendar<br>2. Switch between day, week, and month views | Calendar displays appointments in selected time range correctly |
|
||||
| TC-APP-4.5.5 | Appointment groups | 1. Create multiple appointments for same time slot<br>2. View in calendar | Appointments are grouped/linked appropriately |
|
||||
| TC-APP-4.5.6 | Appointment availability check | 1. Attempt to book appointment during unavailable slot | System shows conflict or prevents double-booking |
|
||||
| TC-APP-4.5.7 | Booking wizard — size/coat selection | 1. Start new appointment booking wizard<br>2. Select a pet with sizeCategory and coatType set<br>3. Observe the service/slot selection step | Size and coat type dropdowns are displayed and persist the pet's existing values |
|
||||
| TC-APP-4.5.8 | Large/X-Large pet slot duration reflects buffer | 1. Add a pet with sizeCategory = "large" or "x-large" to an appointment<br>2. Note the service duration<br>3. Complete booking and inspect the appointment | Appointment slot includes the service duration plus the configured buffer for the pet's size category |
|
||||
| TC-APP-4.5.9 | Appointment overrun cascades downstream | 1. Book three consecutive same-groomer appointments (A → B → C)<br>2. Manually extend appointment A's endTime so it overlaps B's startTime by ≥15 min<br>3. Observe appointment B | Appointment B (and C if still overlapping) is automatically shifted forward by the overrun delta + buffer; no error thrown |
|
||||
| TC-APP-4.5.10 | Cascaded appointments appear at new times | 1. Complete TC-APP-4.5.9<br>2. Check the calendar/list view | Appointments B and C are now shown at their shifted start/end times |
|
||||
| TC-APP-4.5.11 | Client receives reschedule notification email | 1. Complete TC-APP-4.5.9<br>2. Check the client's email (or notification log) | Client receives an email with subject/lines indicating their appointment was rescheduled from original time to new time |
|
||||
| TC-APP-4.5.12 | Appointment flagged when shift crosses day boundary | 1. Book appointment D for late afternoon (e.g. 17:30)<br>2. Extend a prior appointment so D would shift to the next day<br>3. Observe D | Appointment D is flagged for manual review and is NOT auto-shifted to the next day |
|
||||
| TC-APP-4.5.13 | Only scheduled/confirmed appointments are cascaded | 1. Start a cascade scenario (TC-APP-4.5.9) where a downstream appointment is already `in_progress`<br>2. Complete the cascade | The `in_progress` appointment is not shifted; cascade continues to next eligible appointment |
|
||||
|
||||
### 4.6 Services
|
||||
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
import { eq, and, gt, gte, lt, ne, or, asc } from "@groombook/db";
|
||||
import { appointments, clients, pets, services, staff, type Db } from "@groombook/db";
|
||||
import { resolveBufferMinutes } from "./buffer.js";
|
||||
import { sendEmail, buildRescheduleNotificationEmail } from "../services/email.js";
|
||||
|
||||
export interface CascadeResult {
|
||||
shifted: ShiftedAppointment[];
|
||||
flaggedForReview: FlaggedAppointment[];
|
||||
}
|
||||
|
||||
export interface ShiftedAppointment {
|
||||
id: string;
|
||||
oldStartTime: Date;
|
||||
oldEndTime: Date;
|
||||
newStartTime: Date;
|
||||
newEndTime: Date;
|
||||
shiftDeltaMs: number;
|
||||
}
|
||||
|
||||
export interface FlaggedAppointment {
|
||||
id: string;
|
||||
reason: string;
|
||||
requestedStartTime: Date;
|
||||
requestedEndTime: Date;
|
||||
}
|
||||
|
||||
interface AppointmentWithGroomer {
|
||||
id: string;
|
||||
clientId: string;
|
||||
petId: string;
|
||||
serviceId: string;
|
||||
staffId: string | null;
|
||||
batherStaffId: string | null;
|
||||
status: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
bufferMinutes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects and cascades appointment overruns to downstream same-groomer appointments.
|
||||
*
|
||||
* Trigger conditions:
|
||||
* - PATCH extends endTime beyond the original endTime
|
||||
* - Status transitions where current time exceeds endTime + bufferMinutes
|
||||
*
|
||||
* Guard rails:
|
||||
* - Only shifts `scheduled` and `confirmed` appointments
|
||||
* - Skips `in_progress`, `completed`, `cancelled`, `no_show`
|
||||
* - Flags appointments that would fall outside business hours for manual review
|
||||
*/
|
||||
export async function detectAndCascadeOverrun({
|
||||
db,
|
||||
overrunningAppointmentId,
|
||||
newEndTime,
|
||||
originalEndTime,
|
||||
}: {
|
||||
db: Db;
|
||||
overrunningAppointmentId: string;
|
||||
newEndTime: Date;
|
||||
originalEndTime: Date;
|
||||
}): Promise<CascadeResult> {
|
||||
const result: CascadeResult = { shifted: [], flaggedForReview: [] };
|
||||
|
||||
// Fetch the overrunning appointment to get groomer/staff info
|
||||
const [overrunning] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, overrunningAppointmentId))
|
||||
.limit(1);
|
||||
|
||||
if (!overrunning) return result;
|
||||
|
||||
const groomerId = overrunning.staffId;
|
||||
if (!groomerId) return result;
|
||||
|
||||
// Determine the effective buffer for the overrunning appointment
|
||||
const bufferMinutes = await resolveBufferMinutesForAppointment(db, overrunning);
|
||||
const overrunEnd = newEndTime;
|
||||
const effectiveEnd = new Date(overrunEnd.getTime() + bufferMinutes * 60_000);
|
||||
|
||||
// Query same-groomer appointments that start AFTER the overrunning appointment ends
|
||||
// and are ordered by startTime ASC (nearest first)
|
||||
const downstreamAppointments = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, groomerId),
|
||||
gt(appointments.startTime, overrunning.endTime),
|
||||
or(
|
||||
eq(appointments.status, "scheduled"),
|
||||
eq(appointments.status, "confirmed")
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(appointments.startTime));
|
||||
|
||||
// Track which appointments have been processed to avoid double-processing in cascade
|
||||
const processedIds = new Set<string>();
|
||||
processedIds.add(overrunningAppointmentId);
|
||||
|
||||
let currentOverrunEnd = effectiveEnd;
|
||||
|
||||
for (const downstream of downstreamAppointments) {
|
||||
if (processedIds.has(downstream.id)) continue;
|
||||
|
||||
const downstreamBuffer = await resolveBufferMinutesForAppointment(db, downstream);
|
||||
|
||||
// Check if this downstream appointment conflicts with the current overrun end
|
||||
const conflictThreshold = new Date(
|
||||
currentOverrunEnd.getTime() + downstreamBuffer * 60_000
|
||||
);
|
||||
|
||||
if (conflictThreshold <= downstream.startTime) {
|
||||
// No conflict — cascade is complete
|
||||
break;
|
||||
}
|
||||
|
||||
// Conflict detected — need to shift this appointment
|
||||
const shiftDeltaMs = conflictThreshold.getTime() - downstream.startTime.getTime();
|
||||
const newStartTime = new Date(downstream.startTime.getTime() + shiftDeltaMs);
|
||||
const newEndTime = new Date(downstream.endTime.getTime() + shiftDeltaMs);
|
||||
|
||||
// Check business hours (simple: only shift within same calendar day window for now)
|
||||
// A more sophisticated implementation would check actual business hours from businessSettings
|
||||
const isSameDay =
|
||||
newStartTime.toDateString() === downstream.startTime.toDateString();
|
||||
|
||||
if (!isSameDay) {
|
||||
result.flaggedForReview.push({
|
||||
id: downstream.id,
|
||||
reason: `Shifted appointment would fall on a different day (${newStartTime.toDateString()})`,
|
||||
requestedStartTime: newStartTime,
|
||||
requestedEndTime: newEndTime,
|
||||
});
|
||||
// Continue cascade check — we still process downstream appointments
|
||||
currentOverrunEnd = newEndTime;
|
||||
processedIds.add(downstream.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply the shift
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
startTime: newStartTime,
|
||||
endTime: newEndTime,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(appointments.id, downstream.id));
|
||||
|
||||
result.shifted.push({
|
||||
id: downstream.id,
|
||||
oldStartTime: downstream.startTime,
|
||||
oldEndTime: downstream.endTime,
|
||||
newStartTime,
|
||||
newEndTime,
|
||||
shiftDeltaMs,
|
||||
});
|
||||
|
||||
// Update current overrun end for next iteration
|
||||
currentOverrunEnd = newEndTime;
|
||||
processedIds.add(downstream.id);
|
||||
}
|
||||
|
||||
// Send notifications for all shifted appointments
|
||||
for (const shifted of result.shifted) {
|
||||
await notifyShiftedAppointment(db, shifted);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an appointment update represents an overrun that triggers cascade logic.
|
||||
*/
|
||||
export function isOverrun({
|
||||
originalEndTime,
|
||||
newEndTime,
|
||||
originalStartTime,
|
||||
newStartTime,
|
||||
status,
|
||||
currentTime,
|
||||
bufferMinutes,
|
||||
}: {
|
||||
originalEndTime: Date;
|
||||
newEndTime: Date;
|
||||
originalStartTime: Date;
|
||||
newStartTime?: Date;
|
||||
status: string;
|
||||
currentTime: Date;
|
||||
bufferMinutes: number;
|
||||
}): boolean {
|
||||
// Case 1: endTime extended beyond original
|
||||
if (newEndTime > originalEndTime) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Case 2: status transition where current time exceeds endTime + bufferMinutes
|
||||
// This handles cases where an appointment ran long but wasn't explicitly rescheduled
|
||||
if (
|
||||
(status === "in_progress" || status === "completed") &&
|
||||
currentTime > new Date(originalEndTime.getTime() + bufferMinutes * 60_000)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolveBufferMinutesForAppointment(
|
||||
db: Db,
|
||||
appt: AppointmentWithGroomer
|
||||
): Promise<number> {
|
||||
// First check if the appointment has an explicit bufferMinutes override
|
||||
if (appt.bufferMinutes > 0) {
|
||||
return appt.bufferMinutes;
|
||||
}
|
||||
|
||||
// Fall back to buffer time rules based on service + pet characteristics
|
||||
const [pet] = await db
|
||||
.select({ sizeCategory: pets.sizeCategory, coatType: pets.coatType })
|
||||
.from(pets)
|
||||
.where(eq(pets.id, appt.petId))
|
||||
.limit(1);
|
||||
|
||||
if (!pet) return 0;
|
||||
|
||||
return resolveBufferMinutes({
|
||||
serviceId: appt.serviceId,
|
||||
sizeCategory: pet.sizeCategory,
|
||||
coatType: pet.coatType,
|
||||
db,
|
||||
});
|
||||
}
|
||||
|
||||
async function notifyShiftedAppointment(
|
||||
db: Db,
|
||||
shifted: ShiftedAppointment
|
||||
): Promise<void> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
clientName: clients.name,
|
||||
clientEmail: clients.email,
|
||||
clientEmailOptOut: clients.emailOptOut,
|
||||
petName: pets.name,
|
||||
serviceName: services.name,
|
||||
groomerName: staff.name,
|
||||
appointmentStartTime: appointments.startTime,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(clients, eq(clients.id, appointments.clientId))
|
||||
.innerJoin(pets, eq(pets.id, appointments.petId))
|
||||
.innerJoin(services, eq(services.id, appointments.serviceId))
|
||||
.leftJoin(staff, eq(staff.id, appointments.staffId))
|
||||
.where(eq(appointments.id, shifted.id))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return;
|
||||
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
|
||||
|
||||
if (!clientEmail || clientEmailOptOut) return;
|
||||
if (!petName || !serviceName) return;
|
||||
|
||||
console.log(
|
||||
`[cascade] Notifying shift for appointment ${shifted.id}: ` +
|
||||
`${shifted.oldStartTime.toISOString()} → ${shifted.newStartTime.toISOString()}`
|
||||
);
|
||||
|
||||
await sendEmail(
|
||||
buildRescheduleNotificationEmail(clientEmail, {
|
||||
clientName,
|
||||
petName,
|
||||
serviceName,
|
||||
groomerName: groomerName ?? null,
|
||||
oldStartTime: shifted.oldStartTime,
|
||||
newStartTime: shifted.newStartTime,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -21,10 +21,6 @@ import {
|
||||
} from "@groombook/db";
|
||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||
import {
|
||||
detectAndCascadeOverrun,
|
||||
isOverrun,
|
||||
} from "../lib/cascade.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
async function withRetry<T>(
|
||||
@@ -588,7 +584,6 @@ appointmentsRouter.patch(
|
||||
// (fixes #18). Also falls back to the existing staffId when staffId is
|
||||
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
|
||||
let row: typeof appointments.$inferSelect | undefined;
|
||||
let originalEndTime: Date | undefined;
|
||||
try {
|
||||
row = await db.transaction(async (tx) => {
|
||||
const [current] = await tx
|
||||
@@ -600,9 +595,6 @@ appointmentsRouter.patch(
|
||||
throw Object.assign(new Error("not found"), { statusCode: 404 });
|
||||
}
|
||||
|
||||
// Preserve original endTime for cascade detection after update
|
||||
originalEndTime = current.endTime;
|
||||
|
||||
const start = updateFields.startTime
|
||||
? new Date(updateFields.startTime)
|
||||
: current.startTime;
|
||||
@@ -692,29 +684,6 @@ appointmentsRouter.patch(
|
||||
}
|
||||
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
// Cascade delay prevention: detect overrun and shift downstream appointments
|
||||
if (
|
||||
originalEndTime &&
|
||||
updateFields.endTime &&
|
||||
isOverrun({
|
||||
originalEndTime,
|
||||
newEndTime: new Date(updateFields.endTime),
|
||||
originalStartTime: row.startTime,
|
||||
status: row.status,
|
||||
currentTime: new Date(),
|
||||
bufferMinutes: row.bufferMinutes ?? 0,
|
||||
})
|
||||
) {
|
||||
const cascadeResult = await detectAndCascadeOverrun({
|
||||
db,
|
||||
overrunningAppointmentId: id,
|
||||
newEndTime: new Date(updateFields.endTime),
|
||||
originalEndTime,
|
||||
});
|
||||
return c.json({ ...row, cascade: cascadeResult });
|
||||
}
|
||||
|
||||
return c.json(row);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,13 +38,11 @@ bookRouter.get("/services", async (c) => {
|
||||
|
||||
// ─── GET /api/book/availability ─────────────────────────────────────────────
|
||||
// Public: return ISO startTime strings for slots where ≥1 groomer is free
|
||||
// Query params: serviceId (uuid), date (YYYY-MM-DD), petSizeCategory, petCoatType
|
||||
// Query params: serviceId (uuid), date (YYYY-MM-DD)
|
||||
|
||||
bookRouter.get("/availability", async (c) => {
|
||||
const serviceId = c.req.query("serviceId");
|
||||
const dateStr = c.req.query("date");
|
||||
const petSizeCategory = c.req.query("petSizeCategory") ?? undefined;
|
||||
const petCoatType = c.req.query("petCoatType") ?? undefined;
|
||||
|
||||
if (!serviceId || !dateStr) {
|
||||
return c.json({ error: "serviceId and date are required" }, 400);
|
||||
@@ -60,12 +58,6 @@ bookRouter.get("/availability", async (c) => {
|
||||
.where(and(eq(services.id, serviceId), eq(services.active, true)));
|
||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
||||
|
||||
// Buffer-aware duration: extra time for large/x-large or complex coats
|
||||
const extraBuffer = (petSizeCategory === "large" || petSizeCategory === "x-large")
|
||||
? (service.defaultBufferMinutes ?? 0)
|
||||
: 0;
|
||||
const durationMinutes = service.durationMinutes + extraBuffer;
|
||||
|
||||
const groomers = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
@@ -97,7 +89,7 @@ bookRouter.get("/availability", async (c) => {
|
||||
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr,
|
||||
durationMinutes,
|
||||
durationMinutes: service.durationMinutes,
|
||||
groomerIds: groomers.map((g) => g.id),
|
||||
booked,
|
||||
});
|
||||
@@ -120,12 +112,6 @@ const bookingSchema = z.object({
|
||||
petName: z.string().min(1).max(200),
|
||||
petSpecies: z.string().min(1).max(100),
|
||||
petBreed: z.string().max(100).optional(),
|
||||
petSizeCategory: z
|
||||
.enum(["small", "medium", "large", "x-large"])
|
||||
.optional(),
|
||||
petCoatType: z
|
||||
.enum(["smooth", "double", "curly", "wire", "long", "hairless"])
|
||||
.optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
@@ -143,7 +129,7 @@ bookRouter.post(
|
||||
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
|
||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
||||
|
||||
let end = new Date(start.getTime() + service.durationMinutes * 60_000);
|
||||
const end = new Date(start.getTime() + service.durationMinutes * 60_000);
|
||||
|
||||
// Find all active groomers
|
||||
const groomers = await db
|
||||
@@ -205,18 +191,11 @@ bookRouter.post(
|
||||
name: body.petName,
|
||||
species: body.petSpecies,
|
||||
breed: body.petBreed ?? null,
|
||||
sizeCategory: body.petSizeCategory ?? null,
|
||||
coatType: body.petCoatType ?? null,
|
||||
})
|
||||
.returning();
|
||||
const pet = petInserted[0];
|
||||
if (!pet) return c.json({ error: "Failed to create pet" }, 500);
|
||||
|
||||
// Buffer-aware end time: large/x-large pets add service bufferMinutes
|
||||
if (body.petSizeCategory === "large" || body.petSizeCategory === "x-large") {
|
||||
end = new Date(start.getTime() + (service.durationMinutes + (service.defaultBufferMinutes ?? 0)) * 60_000);
|
||||
}
|
||||
|
||||
// Insert appointment in a transaction to guard against race conditions
|
||||
let appointment;
|
||||
try {
|
||||
|
||||
@@ -201,52 +201,3 @@ export function buildWaitlistNotificationEmail(
|
||||
<p>— Groom Book</p>`,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Reschedule notification email ────────────────────────────────────────────
|
||||
|
||||
interface RescheduleEmailData {
|
||||
clientName: string;
|
||||
petName: string;
|
||||
serviceName: string;
|
||||
groomerName: string | null;
|
||||
oldStartTime: Date;
|
||||
newStartTime: Date;
|
||||
}
|
||||
|
||||
export function buildRescheduleNotificationEmail(
|
||||
to: string,
|
||||
data: RescheduleEmailData
|
||||
): Mail.Options {
|
||||
const oldTime = formatDateTime(data.oldStartTime);
|
||||
const newTime = formatDateTime(data.newStartTime);
|
||||
const groomer = data.groomerName ? ` with ${data.groomerName}` : "";
|
||||
return {
|
||||
to,
|
||||
subject: `Appointment Rescheduled — ${data.petName}'s appointment has been moved`,
|
||||
text: [
|
||||
`Hi ${data.clientName},`,
|
||||
``,
|
||||
`Your appointment has been rescheduled.`,
|
||||
``,
|
||||
` Pet: ${data.petName}`,
|
||||
` Service: ${data.serviceName}`,
|
||||
` Was: ${oldTime}${groomer}`,
|
||||
` Now: ${newTime}${groomer}`,
|
||||
``,
|
||||
`If you have any questions or need to make changes, please contact us.`,
|
||||
``,
|
||||
`— Groom Book`,
|
||||
].join("\n"),
|
||||
html: `
|
||||
<p>Hi ${data.clientName},</p>
|
||||
<p>Your appointment has been <strong>rescheduled</strong>.</p>
|
||||
<table style="border-collapse:collapse;margin:1em 0">
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Pet</td><td>${data.petName}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Service</td><td>${data.serviceName}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#ef4444">Was</td><td style="text-decoration:line-through;color:#ef4444">${oldTime}${groomer}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#10b981">Now</td><td style="color:#10b981">${newTime}${groomer}</td></tr>
|
||||
</table>
|
||||
<p>If you have any questions or need to make changes, please contact us.</p>
|
||||
<p>— Groom Book</p>`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@ interface BookingBody {
|
||||
petName: string;
|
||||
petSpecies: string;
|
||||
petBreed: string;
|
||||
petSizeCategory: string;
|
||||
petCoatType: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
@@ -125,8 +123,6 @@ export function BookPage() {
|
||||
petName: "",
|
||||
petSpecies: "",
|
||||
petBreed: "",
|
||||
petSizeCategory: "",
|
||||
petCoatType: "",
|
||||
notes: "",
|
||||
});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
@@ -172,18 +168,14 @@ export function BookPage() {
|
||||
if (!selectedService || !date) return;
|
||||
setSlotsLoading(true);
|
||||
setSelectedSlot(null);
|
||||
const params = new URLSearchParams({
|
||||
serviceId: selectedService.id,
|
||||
date,
|
||||
});
|
||||
if (form.petSizeCategory) params.set("petSizeCategory", form.petSizeCategory);
|
||||
if (form.petCoatType) params.set("petCoatType", form.petCoatType);
|
||||
fetch(`/api/book/availability?${params}`)
|
||||
fetch(
|
||||
`/api/book/availability?serviceId=${encodeURIComponent(selectedService.id)}&date=${encodeURIComponent(date)}`
|
||||
)
|
||||
.then((r) => r.json() as Promise<string[]>)
|
||||
.then(setSlots)
|
||||
.catch(() => setSlots([]))
|
||||
.finally(() => setSlotsLoading(false));
|
||||
}, [selectedService, date, form.petSizeCategory, form.petCoatType]);
|
||||
}, [selectedService, date]);
|
||||
|
||||
function goToStep2(svc: Service) {
|
||||
setSelectedService(svc);
|
||||
@@ -222,8 +214,6 @@ export function BookPage() {
|
||||
petName: form.petName,
|
||||
petSpecies: form.petSpecies,
|
||||
petBreed: form.petBreed || undefined,
|
||||
petSizeCategory: form.petSizeCategory || undefined,
|
||||
petCoatType: form.petCoatType || undefined,
|
||||
notes: form.notes || undefined,
|
||||
}),
|
||||
});
|
||||
@@ -504,36 +494,6 @@ export function BookPage() {
|
||||
placeholder="Golden Retriever"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>Pet size (optional, but encouraged)</label>
|
||||
<select
|
||||
style={input}
|
||||
value={form.petSizeCategory}
|
||||
onChange={(e) => setForm((f) => ({ ...f, petSizeCategory: e.target.value }))}
|
||||
>
|
||||
<option value="">Select size…</option>
|
||||
<option value="small">Small (under 15 lbs)</option>
|
||||
<option value="medium">Medium (15–40 lbs)</option>
|
||||
<option value="large">Large (40–80 lbs)</option>
|
||||
<option value="x-large">X-Large (over 80 lbs)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>Coat type (optional, but encouraged)</label>
|
||||
<select
|
||||
style={input}
|
||||
value={form.petCoatType}
|
||||
onChange={(e) => setForm((f) => ({ ...f, petCoatType: e.target.value }))}
|
||||
>
|
||||
<option value="">Select coat type…</option>
|
||||
<option value="smooth">Smooth</option>
|
||||
<option value="double">Double</option>
|
||||
<option value="curly">Curly</option>
|
||||
<option value="wire">Wire</option>
|
||||
<option value="long">Long</option>
|
||||
<option value="hairless">Hairless</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>Notes for groomer</label>
|
||||
<textarea
|
||||
@@ -568,7 +528,7 @@ export function BookPage() {
|
||||
<div>
|
||||
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
|
||||
<div style={{ fontWeight: 600 }}>{selectedService.name}</div>
|
||||
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes + ((form.petSizeCategory === "large" || form.petSizeCategory === "x-large") ? (selectedService.defaultBufferMinutes ?? 0) : 0))}</div>
|
||||
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
|
||||
@@ -639,8 +599,7 @@ export function BookPage() {
|
||||
setResult(null);
|
||||
setForm({
|
||||
serviceId: "", startTime: "", clientName: "", clientEmail: "",
|
||||
clientPhone: "", petName: "", petSpecies: "", petBreed: "",
|
||||
petSizeCategory: "", petCoatType: "", notes: "",
|
||||
clientPhone: "", petName: "", petSpecies: "", petBreed: "", notes: "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -151,8 +151,6 @@ export const pets = pgTable(
|
||||
name: text("name").notNull(),
|
||||
species: text("species").notNull(),
|
||||
breed: text("breed"),
|
||||
sizeCategory: petSizeCategoryEnum("size_category"),
|
||||
coatType: coatTypeEnum("coat_type"),
|
||||
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
||||
dateOfBirth: timestamp("date_of_birth"),
|
||||
healthAlerts: text("health_alerts"),
|
||||
@@ -164,6 +162,8 @@ export const pets = pgTable(
|
||||
photoKey: text("photo_key"),
|
||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||
image: text("image"),
|
||||
sizeCategory: petSizeCategoryEnum("size_category"),
|
||||
coatType: coatTypeEnum("coat_type"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface Pet {
|
||||
name: string;
|
||||
species: string;
|
||||
breed: string | null;
|
||||
sizeCategory: string | null;
|
||||
coatType: string | null;
|
||||
weightKg: number | null;
|
||||
dateOfBirth: string | null;
|
||||
healthAlerts: string | null;
|
||||
@@ -115,6 +117,7 @@ export interface Appointment {
|
||||
cancelledAt: string | null;
|
||||
confirmationToken: string | null;
|
||||
customerNotes: string | null;
|
||||
bufferMinutes: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user