Files
web/src/portal/sections/PetProfiles.tsx
T
Flea Flicker bc21d6de09
CI / Test (push) Successful in 21s
CI / Test (pull_request) Successful in 22s
CI / Lint & Typecheck (push) Successful in 26s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 25s
CI / Build & Push Docker Image (pull_request) Successful in 20s
Promote dev → uat: GRO-2213 portal booking preferredTime HH:MM:SS fix (#52)
2026-06-08 17:36:16 +00:00

399 lines
14 KiB
TypeScript

import { useState, useEffect } from "react";
import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2, Star } from "lucide-react";
import { PetForm } from "./PetForm.js";
import type { Pet } from "@groombook/types";
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 {
appointments: Appointment[];
}
interface Props {
sessionId: string | null;
readOnly: boolean;
}
function buildHeaders(sessionId: string | null): Record<string, string> {
const headers: Record<string, string> = {};
if (sessionId) {
headers["X-Impersonation-Session-Id"] = sessionId;
}
return headers;
}
export function PetProfiles({ sessionId, readOnly }: Props) {
const [pets, setPets] = useState<Pet[]>([]);
const [appointments, setAppointments] = useState<AppointmentsResponse>({ appointments: [] });
const [selectedPetId, setSelectedPetId] = useState<string>("");
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
const [editingPetId, setEditingPetId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchData() {
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: Pet[] = 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.appointments.filter(a => a.pet?.id === selectedPetId && new Date(a.startTime) <= new Date());
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
async function handlePetSave(updatedPet: Pet) {
setSaving(true);
setSaveError(null);
try {
const res = await fetch(`/api/portal/pets/${updatedPet.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", ...buildHeaders(sessionId) },
body: JSON.stringify(updatedPet),
});
if (!res.ok) throw new Error("Failed to save pet");
const saved: Pet = await res.json();
setPets(prev => prev.map(p => p.id === saved.id ? saved : p));
setEditingPetId(null);
} catch (e) {
setSaveError(e instanceof Error ? e.message : "Failed to save pet");
} finally {
setSaving(false);
}
}
if (editingPet) {
return (
<PetForm
pet={editingPet}
onSave={handlePetSave}
onCancel={() => setEditingPetId(null)}
saving={saving}
saveError={saveError}
/>
);
}
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 (
<div className="space-y-6">
{/* Pet Selector */}
<div className="flex gap-3 overflow-x-auto pb-1">
{pets.map(p => (
<button
key={p.id}
onClick={() => { setSelectedPetId(p.id); setActiveTab("info"); }}
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"
}`}
>
<span className="text-2xl">{p.photoKey ? "🐾" : "🐾"}</span>
<div className="text-left">
<p className="font-medium text-stone-800 text-sm">{p.name}</p>
<p className="text-xs text-stone-500">{p.breed ?? "Unknown breed"}</p>
</div>
</button>
))}
</div>
{/* Profile Header */}
{selectedPet && (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center gap-4">
<div className="w-20 h-20 rounded-2xl bg-(--color-accent-light) flex items-center justify-center text-4xl overflow-hidden">
{selectedPet.photoKey ? (
<span>🐾</span>
) : (
<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 ?? "Unknown breed"} · {(() => { const w = selectedPet.weight ?? selectedPet.weightKg; return w != null && w !== "" ? `${w} kg` : "Unknown weight"; })()}</p>
<p className="text-stone-400 text-xs mt-0.5">
Born {(() => { const d = selectedPet.birthDate ?? selectedPet.dateOfBirth; return d ? new Date(d).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>
)}
{/* Tabs */}
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto">
{([
{ id: "info", label: "Basic Info", icon: PawPrint },
{ id: "medical", label: "Medical", icon: Heart },
{ id: "grooming", label: "Grooming", icon: Scissors },
{ id: "history", label: "History", icon: Clock },
] as const).map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setActiveTab(id)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap ${
activeTab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:text-stone-700"
}`}
>
<Icon size={14} />
{label}
</button>
))}
</div>
{/* Tab Content */}
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
{activeTab === "info" && selectedPet && <BasicInfoTab pet={selectedPet} readOnly={readOnly} />}
{activeTab === "medical" && selectedPet && <MedicalTab pet={selectedPet} readOnly={readOnly} />}
{activeTab === "grooming" && selectedPet && <GroomingTab pet={selectedPet} readOnly={readOnly} />}
{activeTab === "history" && <HistoryTab petHistory={petHistory} />}
</div>
</div>
);
}
export function formatSizeCategory(size?: string | null): string {
if (!size) return "Unknown";
return size
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex flex-col sm:flex-row sm:items-center py-2.5 border-b border-stone-100 last:border-0">
<span className="text-sm text-stone-500 sm:w-40 shrink-0">{label}</span>
<span className="text-sm text-stone-800">{value}</span>
</div>
);
}
function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
const classes = {
low: "bg-green-100 text-green-700",
medium: "bg-amber-100 text-amber-700",
high: "bg-red-100 text-red-700",
};
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${classes[severity]}`}>
{severity.charAt(0).toUpperCase() + severity.slice(1)}
</span>
);
}
export function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
const score = pet.temperamentScore;
const flags = pet.temperamentFlags ?? [];
return (
<div>
<InfoRow label="Name" value={pet.name} />
<InfoRow label="Breed" value={pet.breed || "Unknown"} />
<InfoRow label="Weight" value={(() => { const w = pet.weight ?? pet.weightKg; return w != null && w !== "" ? `${w} kg` : "Unknown"; })()} />
<InfoRow label="Date of Birth" value={(() => { const d = pet.birthDate ?? pet.dateOfBirth; return d ? new Date(d).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"; })()} />
<InfoRow label="Size Category" value={formatSizeCategory(pet.petSizeCategory)} />
{/* Temperament (staff-set, read-only) */}
{(score != null || flags.length > 0) && (
<div className="py-2.5 border-b border-stone-100">
<span className="text-sm text-stone-500 sm:w-40 shrink-0 block mb-1">Temperament</span>
<div className="flex flex-col gap-1.5">
{score != null && (
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map(s => (
<Star
key={s}
size={14}
className={s <= score ? "text-amber-400 fill-amber-400" : "text-stone-300"}
/>
))}
<span className="ml-1 text-xs text-stone-500">({score}/5 · staff-set)</span>
</div>
)}
{flags.length > 0 && (
<div className="flex flex-wrap gap-1">
{flags.map(flag => (
<span key={flag} className="inline-flex items-center px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 text-xs">{flag}</span>
))}
</div>
)}
</div>
</div>
)}
<InfoRow label="Notes" value={pet.healthAlerts || "None"} />
{!readOnly && (
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
Upload Photo
</button>
)}
</div>
);
}
function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
const alerts = pet.medicalAlerts ?? [];
return (
<div className="space-y-3">
{alerts.length === 0 ? (
<p className="text-sm text-stone-400">No medical alerts on file.</p>
) : (
alerts.map(alert => (
<div key={alert.id} className="flex items-start gap-3 py-2 border-b border-stone-100 last:border-0">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-stone-800">{alert.type}</span>
<SeverityBadge severity={alert.severity} />
</div>
{alert.description && (
<p className="text-sm text-stone-500">{alert.description}</p>
)}
</div>
</div>
))
)}
{!readOnly && (
<p className="mt-3 text-xs text-stone-400">
Changes to medical alerts will be flagged for staff review.
</p>
)}
</div>
);
}
function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
const coatType = pet.coatType;
const cuts = pet.preferredCuts ?? [];
return (
<div className="space-y-3">
{coatType && (
<InfoRow
label="Coat Type"
value={<span className="capitalize">{coatType}</span>}
/>
)}
<div className="py-2.5 border-b border-stone-100">
<span className="text-sm text-stone-500 sm:w-40 shrink-0 block mb-1">Preferred Cuts</span>
<div className="flex flex-wrap gap-1">
{cuts.map(cut => (
<span key={cut} className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-stone-100 text-stone-700 text-xs">
{cut}
</span>
))}
{cuts.length === 0 && <span className="text-sm text-stone-400">None on file.</span>}
</div>
</div>
<InfoRow label="Grooming Notes" value={pet.groomingNotes || "None"} />
{!readOnly && (
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
Upload Reference Photo
</button>
)}
</div>
);
}
function HistoryTab({ petHistory }: { petHistory: Appointment[] }) {
return (
<div className="space-y-3">
{petHistory.length === 0 ? (
<p className="text-sm text-stone-400 text-center py-4">No history yet</p>
) : (
petHistory.map(appt => (
<div key={appt.id} className="flex items-center gap-3 py-2 border-b border-stone-50 last:border-0">
<div className="w-8 h-8 rounded-lg bg-stone-100 flex items-center justify-center text-xs text-stone-500">
<Scissors size={14} />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-stone-800">
{appt.service ? "Grooming Service" : "Appointment"}
</p>
<p className="text-xs text-stone-500">
with {appt.staff?.name || "Unknown Groomer"}
</p>
</div>
<span className="text-xs text-stone-400">
{new Date(appt.startTime).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</span>
{appt.reportCardId && (
<span className="text-xs text-(--color-accent-dark) font-medium">Report</span>
)}
</div>
))
)}
</div>
);
}