GRO-1178: client-facing enhanced pet profile editor

- PetForm: coat type dropdown, temperament display (read-only),
  medical alerts editor (add/remove/severity), preferred cuts tag input
- PetProfiles: Medical tab shows severity badges, Grooming tab shows
  coat type + preferred cuts, Basic Info tab shows temperament score/flags
- PetForm.test: component tests for all new interactions
- Shared types updated: MedicalAlert, CoatType, AlertSeverity added

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-20 00:23:16 +00:00
committed by Flea Flicker [agent]
parent 90f6a30b74
commit c7f0635fff
3 changed files with 507 additions and 69 deletions
+104 -32
View File
@@ -1,16 +1,7 @@
import { useState, useEffect } from "react";
import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2 } from "lucide-react";
import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2, Star, X } from "lucide-react";
import { PetForm } from "./PetForm.js";
interface Pet {
id: string;
name: string;
breed: string;
weight: number;
birthDate: string;
photoUrl: string | null;
notes: string | null;
}
import type { Pet } from "../../../../packages/types/src/index.js";
interface Appointment {
id: string;
@@ -69,7 +60,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
throw new Error("Failed to load appointments");
}
const petsData = await petsRes.json();
const petsData: Pet[] = await petsRes.json();
const apptsData: AppointmentsResponse = await apptsRes.json();
setPets(petsData);
@@ -100,10 +91,8 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
if (editingPet) {
return (
<PetForm
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pet={editingPet as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onSave={handlePetSave as any}
pet={editingPet}
onSave={handlePetSave}
onCancel={() => setEditingPetId(null)}
/>
);
@@ -145,10 +134,10 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
p.id === selectedPetId ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 bg-white hover:border-stone-300"
}`}
>
<span className="text-2xl">{p.photoUrl ? "🐾" : "🐾"}</span>
<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}</p>
<p className="text-xs text-stone-500">{p.breed ?? "Unknown breed"}</p>
</div>
</button>
))}
@@ -159,17 +148,17 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
<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.photoUrl ? (
<img src={selectedPet.photoUrl} alt={selectedPet.name} className="w-full h-full object-cover" />
{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} · {selectedPet.weight} lbs</p>
<p className="text-stone-500 text-sm">{selectedPet.breed ?? "Unknown breed"} · {selectedPet.weightKg ? `${selectedPet.weightKg} kg` : "Unknown weight"}</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"}
Born {selectedPet.dateOfBirth ? new Date(selectedPet.dateOfBirth).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"}
</p>
</div>
{!readOnly && (
@@ -213,7 +202,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
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>
@@ -222,14 +211,59 @@ function InfoRow({ label, value }: { label: string; value: string }) {
);
}
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>
);
}
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={`${pet.weight} lbs`} />
<InfoRow label="Date of Birth" value={pet.birthDate ? new Date(pet.birthDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"} />
<InfoRow label="Notes" value={pet.notes || "None"} />
<InfoRow label="Weight" value={pet.weightKg ? `${pet.weightKg} kg` : "Unknown"} />
<InfoRow label="Date of Birth" value={pet.dateOfBirth ? new Date(pet.dateOfBirth).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"} />
{/* 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
@@ -240,12 +274,30 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
}
function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
const alerts = pet.medicalAlerts ?? [];
return (
<div>
<InfoRow label="Notes" value={pet.notes || "No medical notes on file"} />
<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 notes will be flagged for staff review.
Changes to medical alerts will be flagged for staff review.
</p>
)}
</div>
@@ -253,9 +305,29 @@ function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
}
function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
const coatType = pet.coatType;
const cuts = pet.preferredCuts ?? [];
return (
<div>
<InfoRow label="Notes" value={pet.notes || "No grooming notes on file"} />
<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
@@ -295,4 +367,4 @@ function HistoryTab({ petHistory }: { petHistory: Appointment[] }) {
)}
</div>
);
}
}