5fec0c938a
The 'error' useState was declared but never read — only setError was called. Now renders the error message as a red text node when the fetch fails. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
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<BufferRule[]>([]);
|
|
const [services, setServices] = useState<Service[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [form, setForm] = useState<BufferRuleForm>(EMPTY_FORM);
|
|
const [saving, setSaving] = useState(false);
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [editBuffer, setEditBuffer] = useState<string>("");
|
|
|
|
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<string, string | number> = {
|
|
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 (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 size={20} className="animate-spin text-stone-400" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="py-4 text-sm text-red-500">{error}</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2 className="text-base font-semibold text-stone-800">Buffer Rules</h2>
|
|
<p className="text-sm text-stone-500">Extra time rules per service / pet size / coat type</p>
|
|
</div>
|
|
<button
|
|
onClick={() => { setShowForm(!showForm); setFormError(null); }}
|
|
className="px-3 py-1.5 bg-(--color-primary) text-white text-sm rounded-lg hover:bg-(--color-primary-hover)"
|
|
>
|
|
{showForm ? "Cancel" : "+ Add Rule"}
|
|
</button>
|
|
</div>
|
|
|
|
{showForm && (
|
|
<form onSubmit={handleCreate} className="mb-6 p-4 bg-stone-50 rounded-xl border border-stone-200 space-y-3">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-600 mb-1">Service *</label>
|
|
<select
|
|
value={form.serviceId}
|
|
onChange={e => setForm(f => ({ ...f, serviceId: 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)"
|
|
>
|
|
<option value="">Select service…</option>
|
|
{services.map(s => (
|
|
<option key={s.id} value={s.id}>{s.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-600 mb-1">Buffer (minutes) *</label>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
step="1"
|
|
value={form.bufferMinutes}
|
|
onChange={e => 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)"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-600 mb-1">Size Category</label>
|
|
<select
|
|
value={form.sizeCategory}
|
|
onChange={e => setForm(f => ({ ...f, sizeCategory: e.target.value }))}
|
|
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)"
|
|
>
|
|
<option value="">Any</option>
|
|
{SIZE_OPTIONS.filter(s => s).map(s => (
|
|
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-600 mb-1">Coat Type</label>
|
|
<select
|
|
value={form.coatType}
|
|
onChange={e => setForm(f => ({ ...f, coatType: e.target.value }))}
|
|
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)"
|
|
>
|
|
<option value="">Any</option>
|
|
{COAT_OPTIONS.filter(c => c).map(c => (
|
|
<option key={c} value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{formError && <p className="text-sm text-red-500">{formError}</p>}
|
|
<button
|
|
type="submit"
|
|
disabled={saving}
|
|
className="px-4 py-2 bg-(--color-primary) text-white text-sm rounded-lg hover:bg-(--color-primary-hover) disabled:opacity-60"
|
|
>
|
|
{saving ? "Saving…" : "Create Rule"}
|
|
</button>
|
|
</form>
|
|
)}
|
|
|
|
{rules.length === 0 && !showForm ? (
|
|
<p className="text-sm text-stone-400 py-6 text-center">No buffer rules configured yet.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{rules.map(rule => (
|
|
<div key={rule.id} className="flex items-center gap-3 p-3 bg-white rounded-xl border border-stone-200">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium text-stone-800 truncate">{rule.serviceName}</div>
|
|
<div className="text-xs text-stone-500 flex gap-2 flex-wrap">
|
|
{rule.sizeCategory && <span>Size: {rule.sizeCategory}</span>}
|
|
{rule.coatType && <span>Coat: {rule.coatType}</span>}
|
|
</div>
|
|
</div>
|
|
{editingId === rule.id ? (
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
value={editBuffer}
|
|
onChange={e => setEditBuffer(e.target.value)}
|
|
className="w-20 border border-stone-200 rounded px-2 py-1 text-sm"
|
|
/>
|
|
<span className="text-xs text-stone-500">min</span>
|
|
<button onClick={() => saveEdit(rule)} disabled={saving} className="text-xs text-green-600 font-medium">Save</button>
|
|
<button onClick={() => setEditingId(null)} className="text-xs text-stone-500">Cancel</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<span className="text-sm font-medium text-stone-700">{rule.bufferMinutes} min</span>
|
|
<button onClick={() => startEdit(rule)} className="text-xs text-stone-500 hover:text-stone-700 px-2">Edit</button>
|
|
</>
|
|
)}
|
|
{confirmDeleteId === rule.id ? (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-red-500">Delete?</span>
|
|
<button onClick={() => handleDelete(rule.id)} disabled={deletingId === rule.id} className="text-xs text-red-600 font-medium">Confirm</button>
|
|
<button onClick={() => setConfirmDeleteId(null)} className="text-xs text-stone-500">Cancel</button>
|
|
</div>
|
|
) : (
|
|
<button onClick={() => setConfirmDeleteId(rule.id)} className="text-xs text-red-400 hover:text-red-600">Delete</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |