diff --git a/apps/web/src/pages/Appointments.tsx b/apps/web/src/pages/Appointments.tsx index 505953c..4d64b1b 100644 --- a/apps/web/src/pages/Appointments.tsx +++ b/apps/web/src/pages/Appointments.tsx @@ -707,6 +707,7 @@ function AppointmentDetail({ ? `✗ Customer cancelled${appt.cancelledAt ? ` (${new Date(appt.cancelledAt).toLocaleString()})` : ""}` : "Pending"], ["Notes", appt.notes ?? "—"], + ...(appt.customerNotes ? [["Customer Notes", appt.customerNotes] as [string, string]] : []), ...(appt.seriesId ? [["Series slot", `#${(appt.seriesIndex ?? 0) + 1}`] as [string, string]] : []), diff --git a/apps/web/src/portal/mockData.ts b/apps/web/src/portal/mockData.ts index ed52a0e..190b41a 100644 --- a/apps/web/src/portal/mockData.ts +++ b/apps/web/src/portal/mockData.ts @@ -42,6 +42,7 @@ export interface Appointment { price: number; status: "confirmed" | "pending" | "waitlisted" | "completed" | "cancelled"; notes: string; + customerNotes: string; reportCardId?: string; } @@ -177,18 +178,21 @@ export const UPCOMING_APPOINTMENTS: Appointment[] = [ services: ["Full Groom"], addOns: ["De-shedding Treatment"], date: "2026-03-21", time: "10:00 AM", duration: 120, price: 145, status: "confirmed", notes: "Spring shed is heavy — extra undercoat work needed", + customerNotes: "", }, { id: "a2", petId: "p2", petName: "Mochi", groomerId: "g3", groomerName: "Morgan", services: ["Full Groom"], addOns: ["Teeth Brushing"], date: "2026-03-25", time: "2:00 PM", duration: 100, price: 90, status: "confirmed", notes: "First visit with Morgan — patient with anxious pets", + customerNotes: "", }, { id: "a3", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie", services: ["Bath & Brush"], addOns: [], date: "2026-04-18", time: "11:00 AM", duration: 45, price: 55, status: "pending", notes: "", + customerNotes: "", }, ]; @@ -198,48 +202,56 @@ export const PAST_APPOINTMENTS: Appointment[] = [ services: ["Full Groom"], addOns: ["De-shedding Treatment", "Blueberry Facial"], date: "2026-02-15", time: "10:00 AM", duration: 130, price: 160, status: "completed", notes: "", reportCardId: "rc1", + customerNotes: "", }, { id: "pa2", petId: "p2", petName: "Mochi", groomerId: "g2", groomerName: "Alex", services: ["Full Groom"], addOns: ["Teeth Brushing"], date: "2026-02-20", time: "1:00 PM", duration: 100, price: 88, status: "completed", notes: "", reportCardId: "rc2", + customerNotes: "", }, { id: "pa3", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie", services: ["Bath & Brush"], addOns: [], date: "2026-01-18", time: "9:00 AM", duration: 45, price: 55, status: "completed", notes: "", + customerNotes: "", }, { id: "pa4", petId: "p2", petName: "Mochi", groomerId: "g2", groomerName: "Alex", services: ["Puppy's First Groom"], addOns: [], date: "2026-01-10", time: "3:00 PM", duration: 60, price: 62, status: "completed", notes: "", + customerNotes: "", }, { id: "pa5", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie", services: ["Full Groom"], addOns: ["Nail Grinding"], date: "2025-12-20", time: "10:00 AM", duration: 105, price: 132, status: "completed", notes: "Holiday groom", + customerNotes: "", }, { id: "pa6", petId: "p1", petName: "Biscuit", groomerId: "g2", groomerName: "Alex", services: ["Full Groom"], addOns: [], date: "2025-11-15", time: "11:00 AM", duration: 90, price: 110, status: "completed", notes: "", + customerNotes: "", }, { id: "pa7", petId: "p2", petName: "Mochi", groomerId: "g3", groomerName: "Morgan", services: ["Bath & Brush"], addOns: [], date: "2025-11-08", time: "2:00 PM", duration: 45, price: 48, status: "completed", notes: "", + customerNotes: "", }, { id: "pa8", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie", services: ["Bath & Brush"], addOns: ["De-shedding Treatment"], date: "2025-10-12", time: "10:00 AM", duration: 75, price: 85, status: "completed", notes: "", + customerNotes: "", }, ]; diff --git a/apps/web/src/portal/sections/Appointments.tsx b/apps/web/src/portal/sections/Appointments.tsx index fdb9281..2b96952 100644 --- a/apps/web/src/portal/sections/Appointments.tsx +++ b/apps/web/src/portal/sections/Appointments.tsx @@ -1,8 +1,10 @@ import { useState } from "react"; -import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Search, Repeat } from "lucide-react"; +import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Search, Repeat, Loader2 } from "lucide-react"; import { UPCOMING_APPOINTMENTS, PAST_APPOINTMENTS, PETS, SERVICES, GROOMERS } from "../mockData.js"; import type { Appointment, Pet, Service, Groomer } from "../mockData.js"; +const MAX_CUSTOMER_NOTES = 500; + interface Props { readOnly: boolean; } @@ -11,6 +13,12 @@ function formatDate(dateStr: string): string { return new Date(dateStr).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" }); } +function isUpcoming(appt: Appointment): boolean { + const now = new Date(); + const apptDate = new Date(`${appt.date}T${appt.time}`); + return apptDate > now && appt.status !== "cancelled" && appt.status !== "completed"; +} + const STATUS_COLORS: Record = { confirmed: "bg-green-100 text-green-700", pending: "bg-amber-100 text-amber-700", @@ -138,8 +146,11 @@ function AppointmentCard({ {appt.notes && (

{appt.notes}

)} + {isUpcoming(appt) && !readOnly && ( + + )} {appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && ( -
+
@@ -161,6 +172,69 @@ function AppointmentCard({ ); } +function CustomerNotesSection({ appointment: appt }: { appointment: Appointment }) { + const [notes, setNotes] = useState(appt.customerNotes || ""); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + + const isDisabled = appt.status === "completed" || appt.status === "cancelled"; + + async function handleSave() { + setSaving(true); + setError(null); + setSaved(false); + try { + const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ customerNotes: notes }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Failed to save" })); + throw new Error(err.error || `HTTP ${res.status}`); + } + setSaved(true); + setTimeout(() => setSaved(false), 2000); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to save"); + } finally { + setSaving(false); + } + } + + return ( +
+
+ + MAX_CUSTOMER_NOTES ? "text-red-500" : "text-stone-400"}`}> + {notes.length}/{MAX_CUSTOMER_NOTES} + +
+