From ec17f1e8851bd58f334b4850368b4233b0fac7a9 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 21 May 2026 11:12:45 +0000 Subject: [PATCH] feat(GRO-1173): apply buffer rules UI changes to extracted groombook/web repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit ports the GRO-1173 admin UI changes from the app monorepo into the extracted groombook/web repo, using the correct source paths (src/ instead of apps/web/src/): - New BufferRulesSection component (full CRUD UI for /api/buffer-rules) - Default Buffer (minutes) field added to service create/edit form - Size Category and Coat Type dropdowns added to PetForm (portal) - @groombook/types Service interface extended with defaultBufferMinutes - BufferRulesSection embedded in Settings page The PetForm already had coatType — this commit adds petSizeCategory and renders both fields with proper dropdown selectors. Co-Authored-By: Paperclip --- packages/types/src/index.ts | 1 + src/components/BufferRules.tsx | 276 ++++++++++++++++++++++++++++++++ src/pages/Services.tsx | 20 ++- src/pages/Settings.tsx | 5 + src/portal/sections/PetForm.tsx | 20 +++ 5 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 src/components/BufferRules.tsx 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/src/components/BufferRules.tsx b/src/components/BufferRules.tsx new file mode 100644 index 0000000..5103f0a --- /dev/null +++ b/src/components/BufferRules.tsx @@ -0,0 +1,276 @@ +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 ( +
+ +
+ ); + } + + 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/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