feat: add customer notes UI to portal and staff views (GRO-178)
- Add customerNotes field to Appointment type - Add read-only customer notes display in staff appointment detail modal - Add customer notes textarea with save, char counter (500 max), and disabled state - Wire up PATCH /api/portal/appointments/:id/notes in portal UI - Update mockData with customerNotes field Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -707,6 +707,7 @@ function AppointmentDetail({
|
|||||||
? `✗ Customer cancelled${appt.cancelledAt ? ` (${new Date(appt.cancelledAt).toLocaleString()})` : ""}`
|
? `✗ Customer cancelled${appt.cancelledAt ? ` (${new Date(appt.cancelledAt).toLocaleString()})` : ""}`
|
||||||
: "Pending"],
|
: "Pending"],
|
||||||
["Notes", appt.notes ?? "—"],
|
["Notes", appt.notes ?? "—"],
|
||||||
|
...(appt.customerNotes ? [["Customer Notes", appt.customerNotes] as [string, string]] : []),
|
||||||
...(appt.seriesId
|
...(appt.seriesId
|
||||||
? [["Series slot", `#${(appt.seriesIndex ?? 0) + 1}`] as [string, string]]
|
? [["Series slot", `#${(appt.seriesIndex ?? 0) + 1}`] as [string, string]]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface Appointment {
|
|||||||
price: number;
|
price: number;
|
||||||
status: "confirmed" | "pending" | "waitlisted" | "completed" | "cancelled";
|
status: "confirmed" | "pending" | "waitlisted" | "completed" | "cancelled";
|
||||||
notes: string;
|
notes: string;
|
||||||
|
customerNotes: string;
|
||||||
reportCardId?: string;
|
reportCardId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,18 +178,21 @@ export const UPCOMING_APPOINTMENTS: Appointment[] = [
|
|||||||
services: ["Full Groom"], addOns: ["De-shedding Treatment"],
|
services: ["Full Groom"], addOns: ["De-shedding Treatment"],
|
||||||
date: "2026-03-21", time: "10:00 AM", duration: 120, price: 145,
|
date: "2026-03-21", time: "10:00 AM", duration: 120, price: 145,
|
||||||
status: "confirmed", notes: "Spring shed is heavy — extra undercoat work needed",
|
status: "confirmed", notes: "Spring shed is heavy — extra undercoat work needed",
|
||||||
|
customerNotes: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "a2", petId: "p2", petName: "Mochi", groomerId: "g3", groomerName: "Morgan",
|
id: "a2", petId: "p2", petName: "Mochi", groomerId: "g3", groomerName: "Morgan",
|
||||||
services: ["Full Groom"], addOns: ["Teeth Brushing"],
|
services: ["Full Groom"], addOns: ["Teeth Brushing"],
|
||||||
date: "2026-03-25", time: "2:00 PM", duration: 100, price: 90,
|
date: "2026-03-25", time: "2:00 PM", duration: 100, price: 90,
|
||||||
status: "confirmed", notes: "First visit with Morgan — patient with anxious pets",
|
status: "confirmed", notes: "First visit with Morgan — patient with anxious pets",
|
||||||
|
customerNotes: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "a3", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
|
id: "a3", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
|
||||||
services: ["Bath & Brush"], addOns: [],
|
services: ["Bath & Brush"], addOns: [],
|
||||||
date: "2026-04-18", time: "11:00 AM", duration: 45, price: 55,
|
date: "2026-04-18", time: "11:00 AM", duration: 45, price: 55,
|
||||||
status: "pending", notes: "",
|
status: "pending", notes: "",
|
||||||
|
customerNotes: "",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -198,48 +202,56 @@ export const PAST_APPOINTMENTS: Appointment[] = [
|
|||||||
services: ["Full Groom"], addOns: ["De-shedding Treatment", "Blueberry Facial"],
|
services: ["Full Groom"], addOns: ["De-shedding Treatment", "Blueberry Facial"],
|
||||||
date: "2026-02-15", time: "10:00 AM", duration: 130, price: 160,
|
date: "2026-02-15", time: "10:00 AM", duration: 130, price: 160,
|
||||||
status: "completed", notes: "", reportCardId: "rc1",
|
status: "completed", notes: "", reportCardId: "rc1",
|
||||||
|
customerNotes: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "pa2", petId: "p2", petName: "Mochi", groomerId: "g2", groomerName: "Alex",
|
id: "pa2", petId: "p2", petName: "Mochi", groomerId: "g2", groomerName: "Alex",
|
||||||
services: ["Full Groom"], addOns: ["Teeth Brushing"],
|
services: ["Full Groom"], addOns: ["Teeth Brushing"],
|
||||||
date: "2026-02-20", time: "1:00 PM", duration: 100, price: 88,
|
date: "2026-02-20", time: "1:00 PM", duration: 100, price: 88,
|
||||||
status: "completed", notes: "", reportCardId: "rc2",
|
status: "completed", notes: "", reportCardId: "rc2",
|
||||||
|
customerNotes: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "pa3", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
|
id: "pa3", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
|
||||||
services: ["Bath & Brush"], addOns: [],
|
services: ["Bath & Brush"], addOns: [],
|
||||||
date: "2026-01-18", time: "9:00 AM", duration: 45, price: 55,
|
date: "2026-01-18", time: "9:00 AM", duration: 45, price: 55,
|
||||||
status: "completed", notes: "",
|
status: "completed", notes: "",
|
||||||
|
customerNotes: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "pa4", petId: "p2", petName: "Mochi", groomerId: "g2", groomerName: "Alex",
|
id: "pa4", petId: "p2", petName: "Mochi", groomerId: "g2", groomerName: "Alex",
|
||||||
services: ["Puppy's First Groom"], addOns: [],
|
services: ["Puppy's First Groom"], addOns: [],
|
||||||
date: "2026-01-10", time: "3:00 PM", duration: 60, price: 62,
|
date: "2026-01-10", time: "3:00 PM", duration: 60, price: 62,
|
||||||
status: "completed", notes: "",
|
status: "completed", notes: "",
|
||||||
|
customerNotes: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "pa5", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
|
id: "pa5", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
|
||||||
services: ["Full Groom"], addOns: ["Nail Grinding"],
|
services: ["Full Groom"], addOns: ["Nail Grinding"],
|
||||||
date: "2025-12-20", time: "10:00 AM", duration: 105, price: 132,
|
date: "2025-12-20", time: "10:00 AM", duration: 105, price: 132,
|
||||||
status: "completed", notes: "Holiday groom",
|
status: "completed", notes: "Holiday groom",
|
||||||
|
customerNotes: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "pa6", petId: "p1", petName: "Biscuit", groomerId: "g2", groomerName: "Alex",
|
id: "pa6", petId: "p1", petName: "Biscuit", groomerId: "g2", groomerName: "Alex",
|
||||||
services: ["Full Groom"], addOns: [],
|
services: ["Full Groom"], addOns: [],
|
||||||
date: "2025-11-15", time: "11:00 AM", duration: 90, price: 110,
|
date: "2025-11-15", time: "11:00 AM", duration: 90, price: 110,
|
||||||
status: "completed", notes: "",
|
status: "completed", notes: "",
|
||||||
|
customerNotes: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "pa7", petId: "p2", petName: "Mochi", groomerId: "g3", groomerName: "Morgan",
|
id: "pa7", petId: "p2", petName: "Mochi", groomerId: "g3", groomerName: "Morgan",
|
||||||
services: ["Bath & Brush"], addOns: [],
|
services: ["Bath & Brush"], addOns: [],
|
||||||
date: "2025-11-08", time: "2:00 PM", duration: 45, price: 48,
|
date: "2025-11-08", time: "2:00 PM", duration: 45, price: 48,
|
||||||
status: "completed", notes: "",
|
status: "completed", notes: "",
|
||||||
|
customerNotes: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "pa8", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
|
id: "pa8", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie",
|
||||||
services: ["Bath & Brush"], addOns: ["De-shedding Treatment"],
|
services: ["Bath & Brush"], addOns: ["De-shedding Treatment"],
|
||||||
date: "2025-10-12", time: "10:00 AM", duration: 75, price: 85,
|
date: "2025-10-12", time: "10:00 AM", duration: 75, price: 85,
|
||||||
status: "completed", notes: "",
|
status: "completed", notes: "",
|
||||||
|
customerNotes: "",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from "react";
|
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 { UPCOMING_APPOINTMENTS, PAST_APPOINTMENTS, PETS, SERVICES, GROOMERS } from "../mockData.js";
|
||||||
import type { Appointment, Pet, Service, Groomer } from "../mockData.js";
|
import type { Appointment, Pet, Service, Groomer } from "../mockData.js";
|
||||||
|
|
||||||
|
const MAX_CUSTOMER_NOTES = 500;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
readOnly: boolean;
|
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" });
|
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<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
confirmed: "bg-green-100 text-green-700",
|
confirmed: "bg-green-100 text-green-700",
|
||||||
pending: "bg-amber-100 text-amber-700",
|
pending: "bg-amber-100 text-amber-700",
|
||||||
@@ -138,8 +146,11 @@ function AppointmentCard({
|
|||||||
{appt.notes && (
|
{appt.notes && (
|
||||||
<p className="text-sm text-stone-600 bg-stone-50 rounded-lg px-3 py-2 mb-3">{appt.notes}</p>
|
<p className="text-sm text-stone-600 bg-stone-50 rounded-lg px-3 py-2 mb-3">{appt.notes}</p>
|
||||||
)}
|
)}
|
||||||
|
{isUpcoming(appt) && !readOnly && (
|
||||||
|
<CustomerNotesSection appointment={appt} />
|
||||||
|
)}
|
||||||
{appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && (
|
{appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 mt-3">
|
||||||
<button className="text-xs px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
|
<button className="text-xs px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
|
||||||
Reschedule
|
Reschedule
|
||||||
</button>
|
</button>
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<div className="mt-3 p-3 bg-stone-50 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<label className="text-xs font-medium text-stone-600">Notes for your groomer</label>
|
||||||
|
<span className={`text-xs ${notes.length > MAX_CUSTOMER_NOTES ? "text-red-500" : "text-stone-400"}`}>
|
||||||
|
{notes.length}/{MAX_CUSTOMER_NOTES}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={e => setNotes(e.target.value.slice(0, MAX_CUSTOMER_NOTES))}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className="w-full text-sm border border-stone-200 rounded-lg px-3 py-2 resize-none focus:outline-none focus:ring-2 focus:ring-(--color-accent) disabled:bg-stone-100 disabled:text-stone-400"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Any special requests or notes for this appointment..."
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||||
|
{saved && <p className="text-xs text-green-600 mt-1">Saved!</p>}
|
||||||
|
{!isDisabled && (
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || notes === appt.customerNotes}
|
||||||
|
className="mt-2 flex items-center gap-1.5 text-xs px-3 py-1.5 bg-(--color-accent) text-white rounded-lg font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving && <Loader2 size={12} className="animate-spin" />}
|
||||||
|
{saving ? "Saving..." : "Save Notes"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boolean }) {
|
function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boolean }) {
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [selectedPet, setSelectedPet] = useState<Pet | null>(null);
|
const [selectedPet, setSelectedPet] = useState<Pet | null>(null);
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export interface Appointment {
|
|||||||
confirmedAt: string | null;
|
confirmedAt: string | null;
|
||||||
cancelledAt: string | null;
|
cancelledAt: string | null;
|
||||||
confirmationToken: string | null;
|
confirmationToken: string | null;
|
||||||
|
customerNotes: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user