fix(portal): prefix unused totalPending param with underscore

Silence @typescript-eslint/no-unused-vars for totalPending in
PaymentModal — it is accepted as a prop for API compatibility but the
modal computes selectedTotal from selected invoices instead.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Barkley Trimsworth
2026-03-29 19:56:14 +00:00
parent 9caa03723c
commit 38f435375f
89 changed files with 7064 additions and 916 deletions
+172 -109
View File
@@ -1,39 +1,152 @@
import { useState } from "react";
import { PawPrint, Heart, Scissors, Syringe, AlertTriangle, CheckCircle, Clock, Upload, Edit3 } from "lucide-react";
import { PETS, PAST_APPOINTMENTS } from "../mockData.js";
import type { Pet } from "../mockData.js";
import { useState, useEffect } from "react";
import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2 } 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;
}
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 {
upcoming: Appointment[];
past: Appointment[];
}
interface Props {
sessionId: string | null;
readOnly: boolean;
}
type VaxStatus = "valid" | "expiring" | "expired";
const VAX_STATUS_STYLES: Record<VaxStatus, { bg: string; text: string; icon: typeof CheckCircle }> = {
valid: { bg: "bg-green-100", text: "text-green-700", icon: CheckCircle },
expiring: { bg: "bg-amber-100", text: "text-amber-700", icon: Clock },
expired: { bg: "bg-red-100", text: "text-red-700", icon: AlertTriangle },
};
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({ readOnly }: Props) {
const [selectedPetId, setSelectedPetId] = useState<string>(PETS[0]?.id ?? "");
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "vaccinations" | "history">("info");
export function PetProfiles({ sessionId, readOnly }: Props) {
const [pets, setPets] = useState<Pet[]>([]);
const [appointments, setAppointments] = useState<AppointmentsResponse>({ upcoming: [], past: [] });
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);
const pet = PETS.find(p => p.id === selectedPetId)!;
const petHistory = PAST_APPOINTMENTS.filter(a => a.petId === selectedPetId);
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 = 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.past.filter(a => a.pet?.id === selectedPetId);
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
function handlePetSave(updatedPet: Pet) {
setPets(prev => prev.map(p => p.id === updatedPet.id ? updatedPet : p));
setEditingPetId(null);
}
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}
onCancel={() => setEditingPetId(null)}
/>
);
}
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">
{PETS.map(p => (
<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 ${
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.photo}</span>
<span className="text-2xl">{p.photoUrl ? "🐾" : "🐾"}</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>
@@ -43,23 +156,31 @@ export function PetProfiles({ readOnly }: Props) {
</div>
{/* Profile Header */}
<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">
{pet.photo}
{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.photoUrl ? (
<img src={selectedPet.photoUrl} alt={selectedPet.name} className="w-full h-full object-cover" />
) : (
<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-400 text-xs mt-0.5">
Born {selectedPet.birthDate ? new Date(selectedPet.birthDate).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 className="flex-1">
<h2 className="text-xl font-semibold text-stone-800">{pet.name}</h2>
<p className="text-stone-500 text-sm">{pet.breed} · {pet.weight} lbs · {pet.sex === "male" ? "♂" : "♀"} {pet.spayedNeutered ? "(spayed/neutered)" : ""}</p>
<p className="text-stone-400 text-xs mt-0.5">Born {new Date(pet.dob).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}</p>
</div>
{!readOnly && (
<button 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">
@@ -67,7 +188,6 @@ export function PetProfiles({ readOnly }: Props) {
{ id: "info", label: "Basic Info", icon: PawPrint },
{ id: "medical", label: "Medical", icon: Heart },
{ id: "grooming", label: "Grooming", icon: Scissors },
{ id: "vaccinations", label: "Vaccinations", icon: Syringe },
{ id: "history", label: "History", icon: Clock },
] as const).map(({ id, label, icon: Icon }) => (
<button
@@ -85,10 +205,9 @@ export function PetProfiles({ readOnly }: Props) {
{/* Tab Content */}
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
{activeTab === "info" && <BasicInfoTab pet={pet} readOnly={readOnly} />}
{activeTab === "medical" && <MedicalTab pet={pet} readOnly={readOnly} />}
{activeTab === "grooming" && <GroomingTab pet={pet} readOnly={readOnly} />}
{activeTab === "vaccinations" && <VaccinationsTab pet={pet} readOnly={readOnly} />}
{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>
@@ -108,11 +227,10 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
return (
<div>
<InfoRow label="Name" value={pet.name} />
<InfoRow label="Breed" value={pet.breed} />
<InfoRow label="Breed" value={pet.breed || "Unknown"} />
<InfoRow label="Weight" value={`${pet.weight} lbs`} />
<InfoRow label="Date of Birth" value={new Date(pet.dob).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })} />
<InfoRow label="Sex" value={pet.sex === "male" ? "Male" : "Female"} />
<InfoRow label="Spayed/Neutered" value={pet.spayedNeutered ? "Yes" : "No"} />
<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"} />
{!readOnly && (
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
Upload Photo
@@ -125,12 +243,7 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
return (
<div>
<InfoRow label="Allergies" value={pet.allergies} />
<InfoRow label="Skin Conditions" value={pet.skinConditions} />
<InfoRow label="Anxiety Triggers" value={pet.anxietyTriggers} />
<InfoRow label="Aggression Notes" value={pet.aggressionNotes} />
<InfoRow label="Mobility Issues" value={pet.mobilityIssues} />
<InfoRow label="Medications" value={pet.medications} />
<InfoRow label="Notes" value={pet.notes || "No medical notes on file"} />
{!readOnly && (
<p className="mt-3 text-xs text-stone-400">
Changes to medical notes will be flagged for staff review.
@@ -143,10 +256,7 @@ function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
return (
<div>
<InfoRow label="Preferred Cut" value={pet.preferredCut} />
<InfoRow label="Shampoo Preference" value={pet.shampooPreference} />
<InfoRow label="Sensitive Areas" value={pet.sensitiveAreas} />
<InfoRow label="Standing Instructions" value={pet.standingInstructions} />
<InfoRow label="Notes" value={pet.notes || "No grooming notes on file"} />
{!readOnly && (
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
Upload Reference Photo
@@ -156,58 +266,7 @@ function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
);
}
function VaccinationsTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
return (
<div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
<th className="pb-2 font-medium">Vaccine</th>
<th className="pb-2 font-medium">Administered</th>
<th className="pb-2 font-medium">Expires</th>
<th className="pb-2 font-medium">Status</th>
<th className="pb-2 font-medium">Proof</th>
</tr>
</thead>
<tbody>
{pet.vaccinations.map(vax => {
const style = VAX_STATUS_STYLES[vax.status];
const StatusIcon = style.icon;
return (
<tr key={vax.name} className="border-b border-stone-50">
<td className="py-2.5 font-medium text-stone-800">{vax.name}</td>
<td className="py-2.5 text-stone-600">{new Date(vax.lastAdministered).toLocaleDateString()}</td>
<td className="py-2.5 text-stone-600">{new Date(vax.expirationDate).toLocaleDateString()}</td>
<td className="py-2.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${style.bg} ${style.text}`}>
<StatusIcon size={12} />
{vax.status}
</span>
</td>
<td className="py-2.5">
{vax.documentUploaded ? (
<span className="text-green-600 text-xs">Uploaded</span>
) : !readOnly ? (
<button className="flex items-center gap-1 text-xs text-(--color-accent-dark) hover:underline">
<Upload size={12} />
Upload
</button>
) : (
<span className="text-stone-400 text-xs">Missing</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
function HistoryTab({ petHistory }: { petHistory: typeof PAST_APPOINTMENTS }) {
function HistoryTab({ petHistory }: { petHistory: Appointment[] }) {
return (
<div className="space-y-3">
{petHistory.length === 0 ? (
@@ -219,14 +278,18 @@ function HistoryTab({ petHistory }: { petHistory: typeof PAST_APPOINTMENTS }) {
<Scissors size={14} />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-stone-800">{appt.services.join(", ")}</p>
<p className="text-xs text-stone-500">with {appt.groomerName} · ${appt.price}</p>
<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.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
{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>
<span className="text-xs text-(--color-accent-dark) font-medium">Report</span>
)}
</div>
))