diff --git a/.github/workflows/ci.yml b/.gitea/workflows/ci.yml similarity index 97% rename from .github/workflows/ci.yml rename to .gitea/workflows/ci.yml index 905954e..c58375f 100644 --- a/.github/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main, dev] + branches: [main, dev, uat] pull_request: - branches: [main, dev] + branches: [main, dev, uat] workflow_dispatch: inputs: ref: diff --git a/Dockerfile b/Dockerfile index f64fe7b..5aeaba4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,4 +18,4 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf COPY --from=builder /app/dist /usr/share/nginx/html EXPOSE 80 HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:80/ || exit 1 \ No newline at end of file + CMD wget --spider -q http://localhost:80/ || exit 1 \ No newline at end of file diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 655c505..b138ae4 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -103,6 +103,20 @@ export const { signIn, signOut, useSession, changePassword } = authClient; | TC-WEB-5.7.2 | Add pet | Click "Add Pet", fill form, submit | Pet created and linked to client | | TC-WEB-5.7.3 | Edit pet details | Click on pet, modify details, save | Pet updated successfully | | TC-WEB-5.7.4 | Grooming history view | View pet profile | Past appointments/grooming sessions displayed | +| TC-WEB-5.7.5 | Add pet with size/coat | Create pet with Size Category and Coat Type filled | Size and coat type persisted, visible on pet profile | +| TC-WEB-5.7.6 | Edit pet size/coat | Edit existing pet, change size/coat dropdowns | Updated values saved to pet record | +| TC-WEB-5.7.7 | Size/coat optional | Create pet without selecting size or coat | Pet created successfully, fields remain unset | + +### 5.8.1 Buffer Rules Management UI (GRO-1173) + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-WEB-5.8.2 | Buffer rules section visible | Navigate to Settings | "Buffer Rules" section shown with description | +| TC-WEB-5.8.3 | Create buffer rule | Click "+ Add Rule", select service and buffer minutes, submit | Rule appears in list, matches service/size/coat | +| TC-WEB-5.8.4 | Edit buffer minutes inline | Click Edit on a rule, change minutes, click Save | New buffer value reflected in list | +| TC-WEB-5.8.5 | Delete buffer rule | Click Delete, confirm | Rule removed from list | +| TC-WEB-5.8.6 | Create rule with size/coat | Create rule with Size Category or Coat Type specified | Rule shows size/coat tags in list | +| TC-WEB-5.8.7 | Empty state | Navigate to Settings with no rules | "No buffer rules configured yet" message shown | ### 5.8 Appointment Scheduling UI @@ -121,6 +135,8 @@ export const { signIn, signOut, useSession, changePassword } = authClient; | TC-WEB-5.9.1 | Service catalog loads | Navigate to Services | List of available services displayed | | TC-WEB-5.9.2 | Create service | Click "New Service", fill form, submit | Service created successfully | | TC-WEB-5.9.3 | Edit service | Click on service, modify details, save | Service updated successfully | +| TC-WEB-5.9.4 | Create service with default buffer | Create service with "Default buffer time" filled | Buffer shown in service list and form after save | +| TC-WEB-5.9.5 | Edit service buffer | Open existing service, change default buffer minutes | Updated value persisted after save | ### 5.10 Staff Management UI @@ -216,6 +232,57 @@ export const { signIn, signOut, useSession, changePassword } = authClient; | TC-WEB-5.19.8 | Form reset clears size/coat | Complete booking, click "Book another" | Size and coat fields reset to empty | | TC-WEB-5.19.9 | New pet record has size/coat | Complete booking, view created pet in admin | Pet record shows selected size and coat type | +### 5.20 Buffer Rules Management — Admin UI (GRO-1173) + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-WEB-5.20.1 | Buffer rules section loads | Navigate to Settings page (admin) | "Buffer Rules" section visible with "+ Add Rule" button | +| TC-WEB-5.20.2 | Add rule — required fields only | Click "+ Add Rule", select a service, enter buffer minutes, submit | Rule created, appears in list below | +| TC-WEB-5.20.3 | Add rule — with size category | Add rule, select service + size category + buffer minutes | Rule created with size tag shown in list | +| TC-WEB-5.20.4 | Add rule — with coat type | Add rule, select service + coat type + buffer minutes | Rule created with coat tag shown in list | +| TC-WEB-5.20.5 | Add rule — with both size and coat | Add rule, select service + size + coat + buffer minutes | Rule created with both tags shown | +| TC-WEB-5.20.6 | Validation — missing service | Submit form without selecting service | Error: "Service and valid buffer minutes are required" | +| TC-WEB-5.20.7 | Validation — zero buffer | Submit form with 0 buffer minutes | Error: "Service and valid buffer minutes are required" | +| TC-WEB-5.20.8 | Edit rule inline | Click "Edit" on a rule, change buffer value, click "Save" | Rule updated in list | +| TC-WEB-5.20.9 | Cancel edit | Click "Edit", then "Cancel" | Original value unchanged | +| TC-WEB-5.20.10 | Delete rule — confirmation | Click "Delete" on a rule | Confirmation prompt appears | +| TC-WEB-5.20.11 | Confirm delete | On confirmation prompt, click "Confirm" | Rule removed from list | +| TC-WEB-5.20.12 | Cancel delete | On confirmation prompt, click "Cancel" | Rule remains in list | +| TC-WEB-5.20.13 | Empty state | No rules exist | Message: "No buffer rules configured yet." | +| TC-WEB-5.20.14 | Toggle form | Click "+ Add Rule", then "Cancel" | Form hidden, no rule created | + +### 5.21 Service Default Buffer Minutes (GRO-1173) + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-WEB-5.21.1 | Default buffer shown in table | Navigate to Services page | "Default Buffer" column visible in services table | +| TC-WEB-5.21.2 | New service default is 0 | Click "+ Add Service" | Default Buffer field pre-filled with 0 | +| TC-WEB-5.21.3 | Create service with buffer | Fill service form, set Default Buffer = 10, submit | Service created with 10 min default buffer | +| TC-WEB-5.21.4 | Edit service — view buffer | Edit an existing service | Current default buffer value shown in form | +| TC-WEB-5.21.5 | Update buffer on existing service | Edit service, change Default Buffer to 15, save | Buffer updated, table shows 15 min | +| TC-WEB-5.21.6 | Buffer field — zero allowed | Set Default Buffer to 0, save | Service saved with 0 (no default buffer) | +| TC-WEB-5.21.7 | Buffer field — integer only | Enter non-integer value | Field restricts to integer values | + +### 5.22 Pet Profile — Size Category & Coat Type (GRO-1173) + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-WEB-5.22.1 | Size category dropdown visible | Open Add Pet or Edit Pet form (portal) | "Size Category" dropdown visible with options: Small, Medium, Large, X-Large | +| TC-WEB-5.22.2 | Coat type dropdown visible | Open Add Pet or Edit Pet form | "Coat Type" dropdown visible with options: Smooth, Double, Curly, Wire, Long, Hairless | +| TC-WEB-5.22.3 | Size and coat both optional | Submit pet form without selecting size or coat | Pet saved successfully | +| TC-WEB-5.22.4 | Save pet with size category | Select "Large", fill required fields, save | Pet saved with size = "large" | +| TC-WEB-5.22.5 | Save pet with coat type | Select "Curly", fill required fields, save | Pet saved with coat = "curly" | +| TC-WEB-5.22.6 | Size and coat persisted | Save pet with size + coat, edit again | Both fields retain their selected values | +| TC-WEB-5.22.7 | Clear size | Select size, then clear back to default | Size cleared on save | + +### 5.23 Pet Profile — API Persistence & Save UX (GRO-1470) + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-WEB-5.23.1 | Save pet — API persistence | Edit a pet, change a field (e.g. coat type), click Save, reload the page | Changed field retained after reload (proves PATCH round-trip to server) | +| TC-WEB-5.23.2 | Save pet — error state | Trigger an API save failure (e.g. network error) | Error message displayed; edit form stays open; no data cleared | +| TC-WEB-5.23.3 | Save pet — saving indicator | Click Save | Spinner/indicator shown while request is in flight; form controls disabled | + ## 6. Pass/Fail Criteria **Pass:** diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ea46cc2..a83b36d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -71,6 +71,7 @@ export interface Service { basePriceCents: number; durationMinutes: number; active: boolean; + defaultBufferMinutes?: number; createdAt: string; updatedAt: string; } diff --git a/public/demo-pets/dog-basset-brown-white.png b/public/demo-pets/dog-basset-brown-white.png deleted file mode 100644 index 9c9fea0..0000000 --- a/public/demo-pets/dog-basset-brown-white.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96C853FAECD363909C4A0 - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/public/demo-pets/dog-bichon-white-groomed.png b/public/demo-pets/dog-bichon-white-groomed.png deleted file mode 100644 index 2d214d4..0000000 --- a/public/demo-pets/dog-bichon-white-groomed.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96CFC84D7A9333708F278 - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/public/demo-pets/dog-boxer-fawn-athletic.png b/public/demo-pets/dog-boxer-fawn-athletic.png deleted file mode 100644 index 4958ac7..0000000 --- a/public/demo-pets/dog-boxer-fawn-athletic.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96D48D7892E37386B9ACB - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/public/demo-pets/dog-cavalier-cream-gentle.png b/public/demo-pets/dog-cavalier-cream-gentle.png deleted file mode 100644 index a06164b..0000000 --- a/public/demo-pets/dog-cavalier-cream-gentle.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96C25663D703833F23607 - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/public/demo-pets/dog-cocker-buff-friendly.png b/public/demo-pets/dog-cocker-buff-friendly.png deleted file mode 100644 index b4beacc..0000000 --- a/public/demo-pets/dog-cocker-buff-friendly.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96D89851C843332073968 - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/public/demo-pets/dog-dachshund-black-tan.png b/public/demo-pets/dog-dachshund-black-tan.png deleted file mode 100644 index 506c523..0000000 --- a/public/demo-pets/dog-dachshund-black-tan.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96C9C5A03D33730C61AD8 - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/public/demo-pets/dog-pomeranian-white-studio.png b/public/demo-pets/dog-pomeranian-white-studio.png deleted file mode 100644 index 0d13b96..0000000 --- a/public/demo-pets/dog-pomeranian-white-studio.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96BEB91911B30317E3BE8 - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/public/demo-pets/dog-schnauzer-black-groomed.png b/public/demo-pets/dog-schnauzer-black-groomed.png deleted file mode 100644 index 2cdf304..0000000 --- a/public/demo-pets/dog-schnauzer-black-groomed.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96BFB7B92D33535D6D90D - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/public/demo-pets/dog-setter-red-sunlit.png b/public/demo-pets/dog-setter-red-sunlit.png deleted file mode 100644 index 4e1850d..0000000 --- a/public/demo-pets/dog-setter-red-sunlit.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96B8BDF4B473630A2E120 - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/public/demo-pets/dog-sheepdog-merle-running.png b/public/demo-pets/dog-sheepdog-merle-running.png deleted file mode 100644 index d164736..0000000 --- a/public/demo-pets/dog-sheepdog-merle-running.png +++ /dev/null @@ -1,9 +0,0 @@ - - - AccessDenied - You have no right to access this object because of bucket acl. - 69D96D78BFFCAD343037C27C - hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com - 0003-00000001 - https://api.alibabacloud.com/troubleshoot?q=0003-00000001 - diff --git a/src/__tests__/PetForm.test.tsx b/src/__tests__/PetForm.test.tsx index 8c5f3b1..f49e135 100644 --- a/src/__tests__/PetForm.test.tsx +++ b/src/__tests__/PetForm.test.tsx @@ -111,7 +111,7 @@ describe("PetForm", () => { render(); const removeButtons = screen.getAllByRole("button", { name: "" }); if (removeButtons.length === 0) return; - const removeButton = removeButtons[0]; + const removeButton = removeButtons[0]!; if (!removeButton) return; fireEvent.click(removeButton); expect(screen.queryByText("Allergic to chicken")).toBeNull(); diff --git a/src/components/BufferRules.tsx b/src/components/BufferRules.tsx new file mode 100644 index 0000000..1924aba --- /dev/null +++ b/src/components/BufferRules.tsx @@ -0,0 +1,282 @@ +import { useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; + +interface Service { + id: string; + name: string; + description?: string; + basePriceCents: number; + durationMinutes: number; + active: boolean; +} + +interface BufferRule { + id: string; + serviceId: string; + serviceName: string; + sizeCategory?: string; + coatType?: string; + bufferMinutes: number; + createdAt: string; + updatedAt: string; +} + +interface BufferRuleForm { + serviceId: string; + sizeCategory: string; + coatType: string; + bufferMinutes: string; +} + +const EMPTY_FORM: BufferRuleForm = { + serviceId: "", + sizeCategory: "", + coatType: "", + bufferMinutes: "", +}; + +const SIZE_OPTIONS = ["", "small", "medium", "large", "xlarge"] as const; +const COAT_OPTIONS = ["", "smooth", "double", "wire", "curly", "long", "hairless"] as const; + +export function BufferRulesSection() { + const [rules, setRules] = useState([]); + const [services, setServices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState(EMPTY_FORM); + const [saving, setSaving] = useState(false); + const [formError, setFormError] = useState(null); + const [deletingId, setDeletingId] = useState(null); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [editingId, setEditingId] = useState(null); + const [editBuffer, setEditBuffer] = useState(""); + + useEffect(() => { + Promise.all([ + fetch("/api/buffer-rules").then(r => r.ok ? r.json() : []), + fetch("/api/services?includeInactive=true").then(r => r.ok ? r.json() : []), + ]).then(([rulesData, servicesData]) => { + setRules(rulesData as BufferRule[]); + setServices(servicesData as Service[]); + }).catch(() => setError("Failed to load")).finally(() => setLoading(false)); + }, []); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + const mins = parseInt(form.bufferMinutes); + if (!form.serviceId || isNaN(mins) || mins <= 0) { + setFormError("Service and valid buffer minutes are required."); + return; + } + setSaving(true); + setFormError(null); + try { + const body: Record = { + serviceId: form.serviceId, + bufferMinutes: mins, + }; + if (form.sizeCategory) body.sizeCategory = form.sizeCategory; + if (form.coatType) body.coatType = form.coatType; + const res = await fetch("/api/buffer-rules", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})) as { error?: string }; + throw new Error(err.error ?? `HTTP ${res.status}`); + } + const newRule = await res.json() as BufferRule; + setRules(prev => [...prev, newRule]); + setShowForm(false); + setForm(EMPTY_FORM); + } catch (e: unknown) { + setFormError(e instanceof Error ? e.message : "Failed to create rule"); + } finally { + setSaving(false); + } + } + + async function handleDelete(id: string) { + setDeletingId(id); + try { + await fetch(`/api/buffer-rules/${id}`, { method: "DELETE" }); + setRules(prev => prev.filter(r => r.id !== id)); + } finally { + setDeletingId(null); + setConfirmDeleteId(null); + } + } + + function startEdit(rule: BufferRule) { + setEditingId(rule.id); + setEditBuffer(String(rule.bufferMinutes)); + } + + async function saveEdit(rule: BufferRule) { + const mins = parseInt(editBuffer); + if (isNaN(mins) || mins <= 0) return; + setSaving(true); + try { + const res = await fetch(`/api/buffer-rules/${rule.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ bufferMinutes: mins }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const updated = await res.json() as BufferRule; + setRules(prev => prev.map(r => r.id === updated.id ? updated : r)); + } catch { + // silent fail + } finally { + setSaving(false); + setEditingId(null); + setEditBuffer(""); + } + } + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
{error}
+ ); + } + + return ( +
+
+
+

Buffer Rules

+

Extra time rules per service / pet size / coat type

+
+ +
+ + {showForm && ( +
+
+
+ + +
+
+ + setForm(f => ({ ...f, bufferMinutes: e.target.value }))} + required + className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)" + /> +
+
+ + +
+
+ + +
+
+ {formError &&

{formError}

} + +
+ )} + + {rules.length === 0 && !showForm ? ( +

No buffer rules configured yet.

+ ) : ( +
+ {rules.map(rule => ( +
+
+
{rule.serviceName}
+
+ {rule.sizeCategory && Size: {rule.sizeCategory}} + {rule.coatType && Coat: {rule.coatType}} +
+
+ {editingId === rule.id ? ( +
+ setEditBuffer(e.target.value)} + className="w-20 border border-stone-200 rounded px-2 py-1 text-sm" + /> + min + + +
+ ) : ( + <> + {rule.bufferMinutes} min + + + )} + {confirmDeleteId === rule.id ? ( +
+ Delete? + + +
+ ) : ( + + )} +
+ ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index af4b47b..463d3e5 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -25,6 +25,8 @@ interface PetForm { cutStyle: string; shampooPreference: string; specialCareNotes: string; + coatType: string; + sizeCategory: string; } interface VisitLogForm { @@ -38,6 +40,7 @@ const EMPTY_CLIENT: ClientForm = { name: "", email: "", phone: "", address: "", const EMPTY_PET: PetForm = { name: "", species: "Dog", breed: "", weightStr: "", dob: "", healthAlerts: "", groomingNotes: "", cutStyle: "", shampooPreference: "", specialCareNotes: "", + coatType: "", sizeCategory: "", }; const EMPTY_VISIT_LOG: VisitLogForm = { cutStyle: "", productsUsed: "", notes: "", groomedAt: "" }; @@ -209,6 +212,8 @@ export function ClientsPage() { cutStyle: p.cutStyle ?? "", shampooPreference: p.shampooPreference ?? "", specialCareNotes: p.specialCareNotes ?? "", + coatType: p.coatType ?? "", + sizeCategory: p.petSizeCategory ?? "", }); setPetFormError(null); setShowPetForm(true); @@ -315,6 +320,8 @@ export function ClientsPage() { cutStyle: petForm.cutStyle || undefined, shampooPreference: petForm.shampooPreference || undefined, specialCareNotes: petForm.specialCareNotes || undefined, + coatType: petForm.coatType || undefined, + petSizeCategory: petForm.sizeCategory || undefined, }; const res = editingPet ? await fetch(`/api/pets/${editingPet.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) @@ -690,6 +697,34 @@ export function ClientsPage() { setPetForm((f) => ({ ...f, breed: e.target.value }))} style={inputStyle} /> + + + + + + setPetForm((f) => ({ ...f, weightStr: e.target.value }))} style={inputStyle} /> diff --git a/src/pages/Services.tsx b/src/pages/Services.tsx index 7398c9d..60c4932 100644 --- a/src/pages/Services.tsx +++ b/src/pages/Services.tsx @@ -6,6 +6,7 @@ interface ServiceForm { description: string; priceStr: string; durationMinutes: number; + defaultBufferMinutes: number; active: boolean; } @@ -14,6 +15,7 @@ const EMPTY_FORM: ServiceForm = { description: "", priceStr: "", durationMinutes: 60, + defaultBufferMinutes: 0, active: true, }; @@ -55,6 +57,7 @@ export function ServicesPage() { description: s.description ?? "", priceStr: (s.basePriceCents / 100).toFixed(2), durationMinutes: s.durationMinutes, + defaultBufferMinutes: s.defaultBufferMinutes ?? 0, active: s.active, }); setFormError(null); @@ -76,6 +79,7 @@ export function ServicesPage() { description: form.description || undefined, basePriceCents: Math.round(price * 100), durationMinutes: form.durationMinutes, + defaultBufferMinutes: form.defaultBufferMinutes, active: form.active, }; const res = editing @@ -138,7 +142,7 @@ export function ServicesPage() { - {["Name", "Description", "Price", "Duration", "Status", ""].map((h) => ( + {["Name", "Description", "Price", "Duration", "Default Buffer", "Status", ""].map((h) => ( @@ -152,6 +156,7 @@ export function ServicesPage() { +
{h} {s.description ?? "—"} ${(s.basePriceCents / 100).toFixed(2)} {s.durationMinutes} min{(s as Service & { defaultBufferMinutes?: number }).defaultBufferMinutes ?? 0} min + {saveError && ( +

{saveError}

+ )} ); diff --git a/src/portal/sections/PetProfiles.tsx b/src/portal/sections/PetProfiles.tsx index 787eeb3..412b475 100644 --- a/src/portal/sections/PetProfiles.tsx +++ b/src/portal/sections/PetProfiles.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2, Star, X } from "lucide-react"; +import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2, Star } from "lucide-react"; import { PetForm } from "./PetForm.js"; import type { Pet } from "@groombook/types"; @@ -83,9 +83,27 @@ export function PetProfiles({ sessionId, readOnly }: Props) { 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; - function handlePetSave(updatedPet: Pet) { - setPets(prev => prev.map(p => p.id === updatedPet.id ? updatedPet : p)); - setEditingPetId(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(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) { @@ -94,6 +112,8 @@ export function PetProfiles({ sessionId, readOnly }: Props) { pet={editingPet} onSave={handlePetSave} onCancel={() => setEditingPetId(null)} + saving={saving} + saveError={saveError} /> ); }