Owner Administration is complete. Summary of this final stretch of work:

Seed data extended with an inactive staff member, a pending invitation, a redeemed-reward fixture, a service with no eligible staff, and a future-appointment fixture for conflict testing — all idempotent, verified against the dev DB.

Testing brought to 179 unit / 86 integration / 27 E2E tests, all passing together on a freshly seeded, disposable Postgres:

Unit tests for every new pure validator (business hours, categories/services, booking rules, loyalty config, staff creation).
Integration tests covering opening-hours conflicts, last-owner protection, staff deactivation/eligibility conflicts, loyalty redemption's atomic race-guard, and owner-vs-barber/business-isolation authorization — all against a real Postgres.
12 new Playwright owner journeys, which surfaced and fixed two genuine issues along the way: a cookie-clearing race condition in the sign-out-mid-test flow (a real, reproducible timing bug, not just a test artifact), and confirmation that seed fixtures upserted by deterministic ID go stale once "today" has moved on — now handled defensively.
Desktop responsiveness: converted the single-column Business Details, Booking Rules, and Loyalty Configuration forms to a genuine two-column layout at the lg: breakpoint (verified via measured element positions, not just screenshots), confirmed mobile stacking is unaffected.

Final verification: clean tsc, eslint, and next build, plus the full three-suite run against one fresh database as the authoritative pass.
This commit is contained in:
Stefan Lazic committed 2026-08-03 12:56:55 +01:00
1 parent ffee9d6754
commit ec5583f972
73 files changed
+8400 -53

No files matched your search

@@ -0,0 +1,117 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { addStaffAction } from "../actions/staff";
export interface AddStaffFormProps {
roles: { id: string; name: string }[];
services: { id: string; name: string }[];
}
export function AddStaffForm({ roles, services }: AddStaffFormProps) {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [image, setImage] = useState("");
const [bio, setBio] = useState("");
const [roleId, setRoleId] = useState(roles[0]?.id ?? "");
const [serviceIds, setServiceIds] = useState<string[]>([]);
const [isBookableOnline, setIsBookableOnline] = useState(true);
const [error, setError] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [isPending, startTransition] = useTransition();
const toggleService = (id: string) => {
setServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
};
const submit = () => {
setError(null);
startTransition(async () => {
const result = await addStaffAction({ name, email, phone: phone || undefined, image: image || undefined, bio: bio || undefined, roleId, serviceIds, isBookableOnline });
if (!result.success) {
setError(result.error ?? "Couldn't add this staff member.");
return;
}
if (result.inviteToken) {
setInviteLink(`${window.location.origin}/invite/accept?token=${result.inviteToken}`);
} else {
router.push(`/owner/team/${result.staffId}`);
}
});
};
const copyLink = async () => {
if (!inviteLink) return;
await navigator.clipboard.writeText(inviteLink);
setCopied(true);
};
if (inviteLink) {
return (
<div className="space-y-4 rounded-jumbo border-line border bg-white p-6">
<p className="text-sm font-bold">{name} has been added.</p>
<p className="text-muted text-sm">
Email delivery isn&apos;t wired up yet — copy this invite link and send it to them yourself. It lets them
set their own password. Expires in 7 days.
</p>
<div className="border-line bg-stone break-all rounded-lg border p-3 text-xs">{inviteLink}</div>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={copyLink}>
{copied ? "Copied" : "Copy Link"}
</Button>
<Button className="flex-1" onClick={() => router.push("/owner/team")}>
Done
</Button>
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input label="Name" required value={name} onChange={(e) => setName(e.target.value)} />
<Input label="Email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
<Input label="Phone (optional)" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
<Input label="Photo URL (optional)" value={image} onChange={(e) => setImage(e.target.value)} />
<Textarea label="Bio (optional)" value={bio} onChange={(e) => setBio(e.target.value)} />
<Select label="Role" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
{roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Select>
<Switch label="Visible for online booking" checked={isBookableOnline} onCheckedChange={setIsBookableOnline} />
</div>
<div className="rounded-jumbo border-line border bg-white p-6">
<p className="mb-3 text-xs font-bold tracking-widest uppercase">Services Offered</p>
<div className="space-y-3">
{services.map((s) => (
<Checkbox key={s.id} label={s.name} checked={serviceIds.includes(s.id)} onChange={() => toggleService(s.id)} />
))}
</div>
</div>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={submit}>
Add Staff Member
</Button>
</div>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { updateBookingRuleAction } from "../actions/booking-rules";
import type { BookingRuleInput } from "@/services/scheduling/booking-rule.service";
export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
const router = useRouter();
const [form, setForm] = useState(initial);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const set = <K extends keyof BookingRuleInput>(key: K, value: BookingRuleInput[K]) => {
setSaved(false);
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleSave = () => {
setError(null);
startTransition(async () => {
const result = await updateBookingRuleAction(form);
if (!result.success) {
setError(result.error ?? "Couldn't save booking rules.");
return;
}
setSaved(true);
router.refresh();
});
};
return (
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Booking Window</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input
label="Minimum notice (minutes)"
hint="How soon before a slot a booking can be made."
type="number"
min={0}
value={form.minNoticeMinutes}
onChange={(e) => set("minNoticeMinutes", Number(e.target.value))}
/>
<Input
label="Maximum advance booking (days)"
hint="How far ahead customers can book."
type="number"
min={1}
value={form.maxAdvanceDays}
onChange={(e) => set("maxAdvanceDays", Number(e.target.value))}
/>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Cancellation &amp; Rescheduling</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input
label="Free cancellation window (hours)"
hint="Cancelling later than this may count as a late cancellation."
type="number"
min={0}
value={form.cancellationWindowHours}
onChange={(e) => set("cancellationWindowHours", Number(e.target.value))}
/>
<Input
label="Free reschedule window (hours)"
type="number"
min={0}
value={form.rescheduleWindowHours}
onChange={(e) => set("rescheduleWindowHours", Number(e.target.value))}
/>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Scheduling</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Select
label="Appointment slot interval"
value={String(form.slotIntervalMinutes)}
onChange={(e) => set("slotIntervalMinutes", Number(e.target.value))}
>
{[15, 30, 45, 60].map((m) => (
<option key={m} value={m}>
{m} min
</option>
))}
</Select>
<div className="grid grid-cols-2 gap-4">
<Input
label="Buffer before (min)"
type="number"
min={0}
value={form.bufferBeforeMinutes}
onChange={(e) => set("bufferBeforeMinutes", Number(e.target.value))}
/>
<Input
label="Buffer after (min)"
type="number"
min={0}
value={form.bufferAfterMinutes}
onChange={(e) => set("bufferAfterMinutes", Number(e.target.value))}
/>
</div>
<Input
label="Max appointments per day (blank = no limit)"
type="number"
min={1}
value={form.maxAppointmentsPerDay ?? ""}
onChange={(e) => set("maxAppointmentsPerDay", e.target.value ? Number(e.target.value) : null)}
/>
<Input
label="Max appointments per staff member per day (blank = no limit)"
type="number"
min={1}
value={form.maxAppointmentsPerStaffPerDay ?? ""}
onChange={(e) => set("maxAppointmentsPerStaffPerDay", e.target.value ? Number(e.target.value) : null)}
/>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Customer Options</h3>
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
<Switch
label="Customers can choose a barber"
description='If off, "Anyone" is assigned automatically.'
checked={form.customerCanChooseStaff}
onCheckedChange={(v) => set("customerCanChooseStaff", v)}
/>
<Switch
label="Allow guest bookings"
description="Let customers book without an account."
checked={form.allowGuestBooking}
onCheckedChange={(v) => set("allowGuestBooking", v)}
/>
<Switch
label="Require approval for new bookings"
description="Bookings wait for staff confirmation before they're CONFIRMED."
checked={form.requireApproval}
onCheckedChange={(v) => set("requireApproval", v)}
/>
<Switch label="Require deposit" description="Coming soon — not enforced yet." checked={false} disabled />
</div>
</section>
<div className="space-y-4 lg:col-span-2">
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
Saved — the customer booking flow uses these rules immediately. Existing appointments are unaffected.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
</div>
);
}
@@ -0,0 +1,206 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { updateBusinessProfileAction, updateBusinessSettingsAction } from "../actions/business";
const TIMEZONES = ["Europe/London", "Europe/Paris", "Europe/Dublin", "America/New_York", "America/Los_Angeles"];
const CURRENCIES = ["GBP", "USD", "EUR"];
const LOCALES = ["en-GB", "en-US"];
export interface BusinessDetailsInitial {
name: string;
description: string;
logoUrl: string;
phone: string;
email: string;
website: string;
instagramUrl: string;
facebookUrl: string;
timezone: string;
currency: string;
locale: string;
addressLine1: string;
addressLine2: string;
city: string;
region: string;
postalCode: string;
country: string;
cancellationPolicy: string;
lateArrivalPolicy: string;
bookingTerms: string;
privacyPolicyUrl: string;
}
export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsInitial }) {
const router = useRouter();
const [form, setForm] = useState(initial);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const set = <K extends keyof BusinessDetailsInitial>(key: K, value: BusinessDetailsInitial[K]) => {
setSaved(false);
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleSave = () => {
setError(null);
startTransition(async () => {
const profileResult = await updateBusinessProfileAction({
profile: {
name: form.name,
description: form.description || undefined,
logoUrl: form.logoUrl || undefined,
phone: form.phone || undefined,
email: form.email || undefined,
website: form.website || undefined,
instagramUrl: form.instagramUrl || undefined,
facebookUrl: form.facebookUrl || undefined,
timezone: form.timezone,
currency: form.currency,
locale: form.locale,
},
location: {
addressLine1: form.addressLine1,
addressLine2: form.addressLine2 || undefined,
city: form.city,
region: form.region || undefined,
postalCode: form.postalCode,
country: form.country,
},
});
if (!profileResult.success) {
setError(profileResult.error ?? "Couldn't save business details.");
return;
}
const settingsResult = await updateBusinessSettingsAction({
cancellationPolicy: form.cancellationPolicy || undefined,
lateArrivalPolicy: form.lateArrivalPolicy || undefined,
bookingTerms: form.bookingTerms || undefined,
privacyPolicyUrl: form.privacyPolicyUrl || undefined,
});
if (!settingsResult.success) {
setError(settingsResult.error ?? "Couldn't save business policies.");
return;
}
setSaved(true);
router.refresh();
});
};
return (
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Profile</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input label="Business Name" required value={form.name} onChange={(e) => set("name", e.target.value)} />
<Input label="Logo URL" placeholder="https://…" value={form.logoUrl} onChange={(e) => set("logoUrl", e.target.value)} />
<Textarea label="Description" value={form.description} onChange={(e) => set("description", e.target.value)} />
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Contact &amp; Address</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input label="Street Address" required value={form.addressLine1} onChange={(e) => set("addressLine1", e.target.value)} />
<Input label="Address Line 2 (optional)" value={form.addressLine2} onChange={(e) => set("addressLine2", e.target.value)} />
<div className="grid grid-cols-2 gap-4">
<Input label="City" required value={form.city} onChange={(e) => set("city", e.target.value)} />
<Input label="Postcode" required value={form.postalCode} onChange={(e) => set("postalCode", e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<Input label="Region (optional)" value={form.region} onChange={(e) => set("region", e.target.value)} />
<Input label="Country" value={form.country} onChange={(e) => set("country", e.target.value)} />
</div>
<Input label="Phone" type="tel" value={form.phone} onChange={(e) => set("phone", e.target.value)} />
<Input label="Email" type="email" value={form.email} onChange={(e) => set("email", e.target.value)} />
<Input label="Website" type="url" placeholder="https://" value={form.website} onChange={(e) => set("website", e.target.value)} />
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Social Links</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input label="Instagram" placeholder="@yourshop" value={form.instagramUrl} onChange={(e) => set("instagramUrl", e.target.value)} />
<Input label="Facebook" placeholder="facebook.com/yourshop" value={form.facebookUrl} onChange={(e) => set("facebookUrl", e.target.value)} />
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Regional Settings</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Select label="Time Zone" value={form.timezone} onChange={(e) => set("timezone", e.target.value)}>
{TIMEZONES.map((tz) => (
<option key={tz} value={tz}>
{tz}
</option>
))}
</Select>
<div className="grid grid-cols-2 gap-4">
<Select label="Currency" value={form.currency} onChange={(e) => set("currency", e.target.value)}>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</Select>
<Select label="Locale" value={form.locale} onChange={(e) => set("locale", e.target.value)}>
{LOCALES.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</Select>
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Policies</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Textarea
label="Cancellation Policy"
value={form.cancellationPolicy}
onChange={(e) => set("cancellationPolicy", e.target.value)}
/>
<Textarea
label="Late Arrival Policy"
value={form.lateArrivalPolicy}
onChange={(e) => set("lateArrivalPolicy", e.target.value)}
/>
<Textarea label="Booking Terms" value={form.bookingTerms} onChange={(e) => set("bookingTerms", e.target.value)} />
<Input
label="Privacy Policy URL"
type="url"
placeholder="https://"
value={form.privacyPolicyUrl}
onChange={(e) => set("privacyPolicyUrl", e.target.value)}
/>
</div>
</section>
<div className="space-y-4 lg:col-span-2">
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
Saved — changes are live for customers immediately.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
</div>
);
}
@@ -0,0 +1,267 @@
"use client";
import { ChevronDown, ChevronUp, Pencil, Plus, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
createCategoryAction,
deleteCategoryAction,
reorderCategoriesAction,
reorderServicesAction,
setCategoryActiveAction,
setServiceActiveAction,
updateCategoryAction,
} from "../actions/catalogue";
import { ServiceFormSheet } from "./service-form-sheet";
import type { EditableService } from "./service-form-sheet";
export interface CatalogueService {
id: string;
categoryId: string;
name: string;
description: string;
durationMinutes: number;
price: string;
bufferBeforeMinutes: number;
bufferAfterMinutes: number;
isActive: boolean;
allowOnlineBooking: boolean;
}
export interface CatalogueCategory {
id: string;
name: string;
isActive: boolean;
services: CatalogueService[];
}
function formatPrice(price: string): string {
return `£${Number(price).toFixed(2)}`;
}
export function CatalogueManager({ categories }: { categories: CatalogueCategory[] }) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [newCategoryName, setNewCategoryName] = useState("");
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
const [editingCategoryName, setEditingCategoryName] = useState("");
const [sheetOpen, setSheetOpen] = useState(false);
const [sheetCategoryId, setSheetCategoryId] = useState<string>(categories[0]?.id ?? "");
const [editingService, setEditingService] = useState<EditableService | null>(null);
const [isPending, startTransition] = useTransition();
const run = (fn: () => Promise<{ success: boolean; error?: string }>) => {
setError(null);
startTransition(async () => {
const result = await fn();
if (!result.success) {
setError(result.error ?? "Something went wrong.");
return;
}
router.refresh();
});
};
const addCategory = () => {
if (!newCategoryName.trim()) return;
run(async () => {
const result = await createCategoryAction({ name: newCategoryName.trim() });
if (result.success) setNewCategoryName("");
return result;
});
};
const saveCategoryName = (id: string) => {
if (!editingCategoryName.trim()) return;
run(async () => {
const result = await updateCategoryAction(id, { name: editingCategoryName.trim() });
if (result.success) setEditingCategoryId(null);
return result;
});
};
const moveCategory = (index: number, direction: -1 | 1) => {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= categories.length) return;
const reordered = [...categories];
const [moved] = reordered.splice(index, 1);
if (!moved) return;
reordered.splice(targetIndex, 0, moved);
run(() => reorderCategoriesAction(reordered.map((c) => c.id)));
};
const moveService = (category: CatalogueCategory, index: number, direction: -1 | 1) => {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= category.services.length) return;
const reordered = [...category.services];
const [moved] = reordered.splice(index, 1);
if (!moved) return;
reordered.splice(targetIndex, 0, moved);
run(() => reorderServicesAction(category.id, reordered.map((s) => s.id)));
};
const openNewService = (categoryId: string) => {
setEditingService(null);
setSheetCategoryId(categoryId);
setSheetOpen(true);
};
const openEditService = (service: CatalogueService) => {
setEditingService({
id: service.id,
categoryId: service.categoryId,
name: service.name,
description: service.description,
durationMinutes: service.durationMinutes,
price: service.price,
bufferBeforeMinutes: service.bufferBeforeMinutes,
bufferAfterMinutes: service.bufferAfterMinutes,
allowOnlineBooking: service.allowOnlineBooking,
});
setSheetCategoryId(service.categoryId);
setSheetOpen(true);
};
return (
<div className="space-y-8">
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{categories.map((category, categoryIndex) => (
<section key={category.id}>
<div className="mb-3 flex items-center justify-between px-1">
{editingCategoryId === category.id ? (
<div className="flex flex-1 items-center gap-2">
<Input value={editingCategoryName} onChange={(e) => setEditingCategoryName(e.target.value)} />
<Button size="sm" isLoading={isPending} onClick={() => saveCategoryName(category.id)}>
Save
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<h3 className={category.isActive ? "text-sm font-bold" : "text-muted text-sm font-bold line-through"}>
{category.name}
</h3>
<button
type="button"
onClick={() => {
setEditingCategoryId(category.id);
setEditingCategoryName(category.name);
}}
className="text-muted"
aria-label={`Rename ${category.name}`}
>
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
)}
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => moveCategory(categoryIndex, -1)}
disabled={categoryIndex === 0}
aria-label={`Move ${category.name} up`}
className="text-muted disabled:opacity-30"
>
<ChevronUp className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => moveCategory(categoryIndex, 1)}
disabled={categoryIndex === categories.length - 1}
aria-label={`Move ${category.name} down`}
className="text-muted disabled:opacity-30"
>
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</button>
<Switch
checked={category.isActive}
onCheckedChange={(checked) => run(() => setCategoryActiveAction(category.id, checked))}
aria-label={`${category.name} active`}
/>
{category.services.length === 0 && (
<button
type="button"
onClick={() => run(() => deleteCategoryAction(category.id))}
className="text-danger"
aria-label={`Delete ${category.name}`}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
</div>
<div className="rounded-jumbo border-line divide-line divide-y border bg-white">
{category.services.map((service, serviceIndex) => (
<div key={service.id} className="flex items-center gap-3 px-5 py-4">
<div className="flex flex-col">
<button
type="button"
onClick={() => moveService(category, serviceIndex, -1)}
disabled={serviceIndex === 0}
aria-label={`Move ${service.name} up`}
className="text-muted disabled:opacity-30"
>
<ChevronUp className="h-3 w-3" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => moveService(category, serviceIndex, 1)}
disabled={serviceIndex === category.services.length - 1}
aria-label={`Move ${service.name} down`}
className="text-muted disabled:opacity-30"
>
<ChevronDown className="h-3 w-3" aria-hidden="true" />
</button>
</div>
<button type="button" onClick={() => openEditService(service)} className="min-w-0 flex-1 text-left">
<p className={service.isActive ? "text-sm font-bold" : "text-muted text-sm font-bold line-through"}>
{service.name}
</p>
<p className="text-muted mt-0.5 text-xs">
{service.durationMinutes} min · {formatPrice(service.price)}
{!service.allowOnlineBooking && " · Not bookable online"}
</p>
</button>
<Switch
checked={service.isActive}
onCheckedChange={(checked) => run(() => setServiceActiveAction(service.id, checked))}
aria-label={`${service.name} active`}
/>
</div>
))}
<button
type="button"
onClick={() => openNewService(category.id)}
className="text-accent flex w-full items-center gap-3 px-5 py-4 text-xs font-bold tracking-widest uppercase"
>
<Plus className="h-4 w-4" aria-hidden="true" /> Add Service
</button>
</div>
</section>
))}
<div className="flex items-center gap-2">
<Input placeholder="New category name" value={newCategoryName} onChange={(e) => setNewCategoryName(e.target.value)} />
<Button isLoading={isPending} onClick={addCategory}>
Add Category
</Button>
</div>
<ServiceFormSheet
key={editingService?.id ?? "new"}
open={sheetOpen}
onClose={() => setSheetOpen(false)}
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
defaultCategoryId={sheetCategoryId}
existing={editingService}
/>
</div>
);
}
@@ -0,0 +1,216 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { updateLoyaltyConfigAction } from "../actions/loyalty-config";
import type { ProgrammeConfigInput } from "@/services/loyalty/loyalty.service";
const REWARD_TYPES: { value: ProgrammeConfigInput["rewardType"]; label: string }[] = [
{ value: "PERCENTAGE", label: "Percentage Discount" },
{ value: "FIXED_AMOUNT", label: "Fixed-Value Discount" },
{ value: "FREE_SERVICE", label: "Free Service" },
];
export interface LoyaltyConfigFormProps {
initialConfig: ProgrammeConfigInput;
initialEligibleServiceIds: string[];
initialEligibleStaffIds: string[];
services: { id: string; name: string }[];
staff: { id: string; name: string }[];
}
export function LoyaltyConfigForm({
initialConfig,
initialEligibleServiceIds,
initialEligibleStaffIds,
services,
staff,
}: LoyaltyConfigFormProps) {
const router = useRouter();
const [config, setConfig] = useState(initialConfig);
const [eligibleServiceIds, setEligibleServiceIds] = useState(initialEligibleServiceIds);
const [eligibleStaffIds, setEligibleStaffIds] = useState(initialEligibleStaffIds);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const set = <K extends keyof ProgrammeConfigInput>(key: K, value: ProgrammeConfigInput[K]) => {
setSaved(false);
setConfig((prev) => ({ ...prev, [key]: value }));
};
const toggleInList = (list: string[], setList: (v: string[]) => void, id: string) => {
setSaved(false);
setList(list.includes(id) ? list.filter((x) => x !== id) : [...list, id]);
};
const handleSave = () => {
setError(null);
startTransition(async () => {
const result = await updateLoyaltyConfigAction({ config, eligibleServiceIds, eligibleStaffIds });
if (!result.success) {
setError(result.error ?? "Couldn't save loyalty settings.");
return;
}
setSaved(true);
router.refresh();
});
};
return (
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
<section className="rounded-jumbo border-line border bg-white p-6 lg:col-span-2">
<Switch
label="Loyalty programme enabled"
description="Turn off to stop earning/redeeming everywhere, without losing the configuration below."
checked={config.isEnabled}
onCheckedChange={(v) => set("isEnabled", v)}
/>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Reward Rules</h3>
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
<Input
label="Stamps required for reward"
type="number"
min={1}
value={config.stampsRequired}
onChange={(e) => set("stampsRequired", Number(e.target.value))}
/>
<Select label="Reward Type" value={config.rewardType} onChange={(e) => set("rewardType", e.target.value as ProgrammeConfigInput["rewardType"])}>
{REWARD_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
{config.rewardType === "FREE_SERVICE" ? (
<Select
label="Free Service"
value={config.freeServiceId ?? ""}
onChange={(e) => set("freeServiceId", e.target.value)}
>
<option value="">Choose a service…</option>
{services.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</Select>
) : (
<Input
label={config.rewardType === "PERCENTAGE" ? "Percentage off (1–100)" : "Fixed amount off"}
value={config.rewardValue ?? ""}
onChange={(e) => set("rewardValue", e.target.value)}
placeholder={config.rewardType === "PERCENTAGE" ? "50" : "10.00"}
/>
)}
<Input label="Reward Name" value={config.rewardLabel ?? ""} onChange={(e) => set("rewardLabel", e.target.value)} placeholder="e.g. 50% Off Any Service" />
<Input
label="Maximum reward value (optional)"
value={config.maxRewardValue ?? ""}
onChange={(e) => set("maxRewardValue", e.target.value)}
hint="Caps a percentage discount so it never exceeds this amount."
/>
</div>
</section>
<section className="space-y-3 lg:col-span-2">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Eligibility</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-2 lg:gap-6 lg:space-y-0">
<div>
<p className="mb-2 text-xs font-bold">Eligible services (none checked = all services earn stamps)</p>
<div className="space-y-2">
{services.map((s) => (
<Checkbox
key={s.id}
label={s.name}
checked={eligibleServiceIds.includes(s.id)}
onChange={() => toggleInList(eligibleServiceIds, setEligibleServiceIds, s.id)}
/>
))}
</div>
</div>
<div className="border-line border-t pt-4 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
<p className="mb-2 text-xs font-bold">Eligible staff (none checked = all staff earn stamps)</p>
<div className="space-y-2">
{staff.map((s) => (
<Checkbox
key={s.id}
label={s.name}
checked={eligibleStaffIds.includes(s.id)}
onChange={() => toggleInList(eligibleStaffIds, setEligibleStaffIds, s.id)}
/>
))}
</div>
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules &amp; Expiry</h3>
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
<Switch label="Apply reward automatically" checked={config.autoApplyReward} onCheckedChange={(v) => set("autoApplyReward", v)} />
<Switch
label="Require staff approval to redeem"
description="Redemption already requires a permitted staff member — this is informational only this phase."
checked={config.requiresStaffApproval}
onCheckedChange={(v) => set("requiresStaffApproval", v)}
/>
<Switch label="Earn stamps on cancelled appointments" checked={config.earnOnCancelled} onCheckedChange={(v) => set("earnOnCancelled", v)} />
<Switch label="Earn stamps on no-shows" checked={config.earnOnNoShow} onCheckedChange={(v) => set("earnOnNoShow", v)} />
<div className="grid grid-cols-2 gap-4">
<Input
label="Stamps expire after (days, optional)"
type="number"
min={1}
value={config.stampsExpireAfterDays ?? ""}
onChange={(e) => set("stampsExpireAfterDays", e.target.value ? Number(e.target.value) : undefined)}
/>
<Input
label="Reward expires after (days, optional)"
type="number"
min={1}
value={config.rewardExpiresAfterDays ?? ""}
onChange={(e) => set("rewardExpiresAfterDays", e.target.value ? Number(e.target.value) : undefined)}
/>
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Card Appearance</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input label="Card background color" value={config.cardBackgroundColor ?? ""} onChange={(e) => set("cardBackgroundColor", e.target.value)} placeholder="#111b15" />
<Input label="Card background image URL (optional)" value={config.cardBackgroundImageUrl ?? ""} onChange={(e) => set("cardBackgroundImageUrl", e.target.value)} />
<Input label="Stamp icon" value={config.stampIcon ?? ""} onChange={(e) => set("stampIcon", e.target.value)} placeholder="scissors" />
<Input label="Terms line" value={config.termsLine ?? ""} onChange={(e) => set("termsLine", e.target.value)} placeholder="Valid for 12 months. One reward per visit." />
</div>
</section>
<div className="space-y-4 lg:col-span-2">
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
Saved.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
</div>
);
}
@@ -0,0 +1,201 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Weekday } from "@/generated/prisma/enums";
import { updateOpeningHoursAction } from "../actions/business";
import type { OpeningHoursActionResult } from "../actions/business";
const DAY_ORDER: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
const DAY_LABEL: Record<Weekday, string> = {
MONDAY: "Monday",
TUESDAY: "Tuesday",
WEDNESDAY: "Wednesday",
THURSDAY: "Thursday",
FRIDAY: "Friday",
SATURDAY: "Saturday",
SUNDAY: "Sunday",
};
function toTimeString(minutes: number | null): string {
if (minutes === null) return "09:00";
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
function toMinutes(time: string): number {
const [h, m] = time.split(":").map(Number);
return (h ?? 0) * 60 + (m ?? 0);
}
interface DayState {
dayOfWeek: Weekday;
isClosed: boolean;
startTime: string;
endTime: string;
}
export interface InitialOpeningHoursRow {
dayOfWeek: Weekday;
isClosed: boolean;
opensAtMinutes: number | null;
closesAtMinutes: number | null;
}
export function OpeningHoursForm({ initialRows }: { initialRows: InitialOpeningHoursRow[] }) {
const router = useRouter();
const [days, setDays] = useState<DayState[]>(() =>
DAY_ORDER.map((dayOfWeek) => {
const existing = initialRows.find((r) => r.dayOfWeek === dayOfWeek);
return {
dayOfWeek,
isClosed: existing?.isClosed ?? true,
startTime: toTimeString(existing?.opensAtMinutes ?? null),
endTime: toTimeString(existing?.closesAtMinutes ?? 19 * 60),
};
}),
);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [conflicts, setConflicts] = useState<NonNullable<OpeningHoursActionResult["conflicts"]>>([]);
const [isPending, startTransition] = useTransition();
const updateDay = (dayOfWeek: Weekday, patch: Partial<DayState>) => {
setSaved(false);
setConflicts([]);
setDays((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d)));
};
const copyMondayToAll = () => {
const monday = days.find((d) => d.dayOfWeek === Weekday.MONDAY);
if (!monday) return;
setSaved(false);
setConflicts([]);
setDays((prev) =>
prev.map((d) => (d.dayOfWeek === Weekday.MONDAY ? d : { ...d, isClosed: monday.isClosed, startTime: monday.startTime, endTime: monday.endTime })),
);
};
const submit = (acknowledgeConflicts: boolean) => {
setError(null);
startTransition(async () => {
const result = await updateOpeningHoursAction(
days.map((d) => ({
dayOfWeek: d.dayOfWeek,
isClosed: d.isClosed,
...(d.isClosed ? {} : { opensAtMinutes: toMinutes(d.startTime), closesAtMinutes: toMinutes(d.endTime) }),
})),
acknowledgeConflicts,
);
if (!result.success) {
setError(result.error ?? "Couldn't save opening hours.");
setConflicts(result.conflicts ?? []);
return;
}
setSaved(true);
setConflicts([]);
router.refresh();
});
};
if (conflicts.length > 0) {
return (
<div className="space-y-4">
<p role="alert" className="text-danger text-sm font-medium">
These hours would leave {conflicts.length} upcoming appointment{conflicts.length > 1 ? "s" : ""} outside
the business&apos;s open hours. They won&apos;t be cancelled automatically — review them, then confirm if
you still want to save.
</p>
<ul className="space-y-2">
{conflicts.map((c) => (
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">
{c.serviceNames} · with {c.staffName}
</p>
</li>
))}
</ul>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={() => setConflicts([])}>
Go Back
</Button>
<Button variant="destructive" className="flex-1" isLoading={isPending} onClick={() => submit(true)}>
Save Anyway
</Button>
</div>
</div>
);
}
return (
<div className="space-y-4">
<button type="button" onClick={copyMondayToAll} className="text-accent text-xs font-bold tracking-widest uppercase underline">
Copy Monday to all days
</button>
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
{days.map((day) => (
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex items-center gap-3">
<Switch
checked={!day.isClosed}
onCheckedChange={(checked) => updateDay(day.dayOfWeek, { isClosed: !checked })}
aria-label={`${DAY_LABEL[day.dayOfWeek]} open`}
/>
<p className="text-sm font-bold">{DAY_LABEL[day.dayOfWeek]}</p>
</div>
{day.isClosed ? (
<span className="text-muted text-xs">Closed</span>
) : (
<div className="flex items-center gap-2">
<input
type="time"
value={day.startTime}
onChange={(e) => updateDay(day.dayOfWeek, { startTime: e.target.value })}
className="border-line rounded-lg border px-2 py-1.5 text-xs"
aria-label={`${DAY_LABEL[day.dayOfWeek]} opening time`}
/>
<span className="text-muted text-xs">to</span>
<input
type="time"
value={day.endTime}
onChange={(e) => updateDay(day.dayOfWeek, { endTime: e.target.value })}
className="border-line rounded-lg border px-2 py-1.5 text-xs"
aria-label={`${DAY_LABEL[day.dayOfWeek]} closing time`}
/>
</div>
)}
</div>
))}
</div>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
Saved. The booking engine reflects these hours immediately.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
Save
</Button>
</div>
);
}
@@ -0,0 +1,127 @@
"use client";
import { CalendarOff } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { BottomSheet } from "@/components/ui/bottom-sheet";
import { Input } from "@/components/ui/input";
import type { OwnerBlockTimeConflict } from "../actions/calendar";
import { ownerBlockTimeAction } from "../actions/calendar";
function toMinutes(hhmm: string): number | null {
const match = /^(\d{2}):(\d{2})$/.exec(hhmm);
if (!match) return null;
return Number(match[1]) * 60 + Number(match[2]);
}
/** Owner variant of the staff BlockTimeButton (src/features/staff/components/block-time-button.tsx) — same UI, targets a chosen staff member instead of only the signed-in user. */
export function OwnerBlockTimeButton({ staffId, staffName, date }: { staffId: string; staffName: string; date: string }) {
const [open, setOpen] = useState(false);
const [startTime, setStartTime] = useState("12:00");
const [endTime, setEndTime] = useState("13:00");
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const [conflicts, setConflicts] = useState<OwnerBlockTimeConflict[] | null>(null);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const handleClose = () => {
setOpen(false);
setError(null);
setConflicts(null);
setReason("");
};
const submit = (acknowledgeConflicts: boolean) => {
const startMinutes = toMinutes(startTime);
const endMinutes = toMinutes(endTime);
if (startMinutes === null || endMinutes === null || startMinutes >= endMinutes) {
setError("Start time must be before end time.");
return;
}
setError(null);
startTransition(async () => {
const result = await ownerBlockTimeAction({ staffId, date, startMinutes, endMinutes, reason, acknowledgeConflicts });
if (!result.success) {
setError(result.error ?? "Something went wrong.");
setConflicts(result.conflicts ?? null);
return;
}
setOpen(false);
setConflicts(null);
router.refresh();
});
};
return (
<>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
<CalendarOff className="h-4 w-4" aria-hidden="true" />
Block Time
</Button>
<BottomSheet open={open} onClose={handleClose} title={`Block Time — ${staffName}`}>
<div className="space-y-4 pb-2">
<p className="text-muted text-sm">
Reserve part of{" "}
{new Date(`${date}T00:00:00`).toLocaleDateString("en-GB", {
weekday: "long",
day: "numeric",
month: "short",
})}{" "}
on {staffName}&apos;s schedule for something other than appointments.
</p>
{conflicts && conflicts.length > 0 ? (
<div className="space-y-3">
<p role="alert" className="text-danger text-sm font-medium">
This overlaps {conflicts.length} existing appointment
{conflicts.length > 1 ? "s" : ""}:
</p>
<ul className="space-y-2">
{conflicts.map((c) => (
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
</li>
))}
</ul>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={handleClose}>
Cancel
</Button>
<Button variant="destructive" className="flex-1" isLoading={isPending} onClick={() => submit(true)}>
Block Anyway
</Button>
</div>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-4">
<Input label="Start" type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
<Input label="End" type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
</div>
<Input
label="Reason (optional)"
placeholder="e.g. Team meeting"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
Block This Time
</Button>
</>
)}
</div>
</BottomSheet>
</>
);
}
@@ -0,0 +1,94 @@
"use client";
import { Gift } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
import { redeemRewardAction } from "../actions/loyalty";
/**
* Available to any staff member with MANAGE_LOYALTY_STAMPS (Owner and
* Barber both hold it in the seed — see redeemRewardAction) — surfaced on
* both the owner and staff appointment details pages, since redemption is
* an operational action, not owner-only configuration. See
* docs/phase-6-summary.md, "Loyalty configuration and redemption approach".
*/
export function RedeemRewardButton({
appointmentId,
rewardId,
rewardLabel,
subtotal,
}: {
appointmentId: string;
rewardId: string;
rewardLabel: string;
subtotal: number;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<{ discountAmount: string } | null>(null);
const [isPending, startTransition] = useTransition();
const close = () => {
const shouldRefresh = !!result;
setOpen(false);
setError(null);
setResult(null);
if (shouldRefresh) router.refresh();
};
const submit = () => {
setError(null);
startTransition(async () => {
const res = await redeemRewardAction({ rewardId, appointmentId });
if (!res.success) {
setError(res.error ?? "Couldn't redeem this reward.");
return;
}
setResult({ discountAmount: res.discountAmount ?? "0.00" });
});
};
return (
<>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
<Gift className="text-accent-loyalty h-4 w-4" aria-hidden="true" />
Redeem Reward
</Button>
<Modal open={open} onClose={close} title={result ? "Reward Redeemed" : "Redeem Reward"}>
{result ? (
<div className="space-y-4">
<p role="status" className="text-muted text-sm">
{rewardLabel} has been applied to this appointment — £{result.discountAmount} off.
</p>
<Button className="w-full" onClick={close}>
Done
</Button>
</div>
) : (
<div className="space-y-4">
<p className="text-muted text-sm">
Apply <span className="text-ink font-bold">{rewardLabel}</span> to this appointment
(subtotal £{subtotal.toFixed(2)})? This can&apos;t be undone.
</p>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={close}>
Cancel
</Button>
<Button className="flex-1" isLoading={isPending} onClick={submit}>
Apply Reward
</Button>
</div>
</div>
)}
</Modal>
</>
);
}
@@ -0,0 +1,112 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { BottomSheet } from "@/components/ui/bottom-sheet";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { createServiceAction, updateServiceAction } from "../actions/catalogue";
export interface EditableService {
id: string;
categoryId: string;
name: string;
description: string;
durationMinutes: number;
price: string;
bufferBeforeMinutes: number;
bufferAfterMinutes: number;
allowOnlineBooking: boolean;
}
export function ServiceFormSheet({
open,
onClose,
categories,
defaultCategoryId,
existing,
}: {
open: boolean;
onClose: () => void;
categories: { id: string; name: string }[];
defaultCategoryId: string;
existing?: EditableService | null;
}) {
const router = useRouter();
const [categoryId, setCategoryId] = useState(existing?.categoryId ?? defaultCategoryId);
const [name, setName] = useState(existing?.name ?? "");
const [description, setDescription] = useState(existing?.description ?? "");
const [duration, setDuration] = useState(String(existing?.durationMinutes ?? 30));
const [price, setPrice] = useState(existing?.price ?? "");
const [bufferBefore, setBufferBefore] = useState(String(existing?.bufferBeforeMinutes ?? 0));
const [bufferAfter, setBufferAfter] = useState(String(existing?.bufferAfterMinutes ?? 0));
const [allowOnlineBooking, setAllowOnlineBooking] = useState(existing?.allowOnlineBooking ?? true);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const submit = () => {
setError(null);
const durationMinutes = Number(duration);
const bufferBeforeMinutes = Number(bufferBefore);
const bufferAfterMinutes = Number(bufferAfter);
if (!Number.isInteger(durationMinutes) || durationMinutes <= 0) {
setError("Duration must be a positive number of minutes.");
return;
}
startTransition(async () => {
const input = {
categoryId,
name,
description: description || undefined,
durationMinutes,
price,
bufferBeforeMinutes,
bufferAfterMinutes,
allowOnlineBooking,
};
const result = existing ? await updateServiceAction(existing.id, input) : await createServiceAction(input);
if (!result.success) {
setError(result.error ?? "Something went wrong.");
return;
}
onClose();
router.refresh();
});
};
return (
<BottomSheet open={open} onClose={onClose} title={existing ? "Edit Service" : "New Service"}>
<div className="space-y-4 pb-2">
<Select label="Category" value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} />
<Textarea label="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
<div className="grid grid-cols-2 gap-4">
<Input label="Duration (min)" type="number" min={1} value={duration} onChange={(e) => setDuration(e.target.value)} />
<Input label="Price" placeholder="22.50" value={price} onChange={(e) => setPrice(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<Input label="Buffer Before (min)" type="number" min={0} value={bufferBefore} onChange={(e) => setBufferBefore(e.target.value)} />
<Input label="Buffer After (min)" type="number" min={0} value={bufferAfter} onChange={(e) => setBufferAfter(e.target.value)} />
</div>
<Switch label="Available for online booking" checked={allowOnlineBooking} onCheckedChange={setAllowOnlineBooking} />
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={submit}>
{existing ? "Save Changes" : "Add Service"}
</Button>
</div>
</BottomSheet>
);
}
@@ -0,0 +1,282 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Select } from "@/components/ui/select";
import {
deactivateStaffAction,
reactivateStaffAction,
regenerateInvitationAction,
setStaffServiceEligibilityAction,
updateStaffAction,
} from "../actions/staff";
export interface StaffDetailPanelProps {
staffId: string;
currentRoleId: string;
roles: { id: string; name: string }[];
allServices: { id: string; name: string }[];
initialServiceIds: string[];
status: "ACTIVE" | "ON_BREAK" | "INACTIVE";
hasPendingInvitation: boolean;
}
type ConflictItem = { id: string; customerName: string; serviceNames: string; startAt: string };
export function StaffDetailPanel({
staffId,
currentRoleId,
roles,
allServices,
initialServiceIds,
status,
hasPendingInvitation,
}: StaffDetailPanelProps) {
const router = useRouter();
const [roleId, setRoleId] = useState(currentRoleId);
const [serviceIds, setServiceIds] = useState(initialServiceIds);
const [roleError, setRoleError] = useState<string | null>(null);
const [roleSaved, setRoleSaved] = useState(false);
const [servicesError, setServicesError] = useState<string | null>(null);
const [servicesSaved, setServicesSaved] = useState(false);
const [serviceConflicts, setServiceConflicts] = useState<ConflictItem[]>([]);
const [deactivateConflicts, setDeactivateConflicts] = useState<ConflictItem[] | null>(null);
const [deactivateError, setDeactivateError] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [isRolePending, startRoleTransition] = useTransition();
const [isServicesPending, startServicesTransition] = useTransition();
const [isDeactivatePending, startDeactivateTransition] = useTransition();
const [isInvitePending, startInviteTransition] = useTransition();
const saveRole = () => {
setRoleError(null);
setRoleSaved(false);
startRoleTransition(async () => {
const result = await updateStaffAction(staffId, { roleId });
if (!result.success) {
setRoleError(result.error ?? "Couldn't change role.");
return;
}
setRoleSaved(true);
router.refresh();
});
};
const toggleService = (id: string) => {
setServicesSaved(false);
setServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
};
const saveServices = (acknowledgeConflicts: boolean) => {
setServicesError(null);
startServicesTransition(async () => {
const result = await setStaffServiceEligibilityAction(staffId, serviceIds, acknowledgeConflicts);
if (!result.success) {
setServicesError(result.error ?? "Couldn't update services.");
setServiceConflicts(result.conflicts ?? []);
return;
}
setServicesSaved(true);
setServiceConflicts([]);
router.refresh();
});
};
const deactivate = (acknowledgeConflicts: boolean) => {
setDeactivateError(null);
startDeactivateTransition(async () => {
const result = await deactivateStaffAction(staffId, acknowledgeConflicts);
if (!result.success) {
setDeactivateError(result.error ?? "Couldn't deactivate this staff member.");
setDeactivateConflicts(result.conflicts ?? []);
return;
}
setDeactivateConflicts(null);
router.refresh();
});
};
const reactivate = () => {
startDeactivateTransition(async () => {
await reactivateStaffAction(staffId);
router.refresh();
});
};
const regenerateLink = () => {
startInviteTransition(async () => {
const result = await regenerateInvitationAction(staffId);
if (result.success && result.inviteToken) {
setInviteLink(`${window.location.origin}/invite/accept?token=${result.inviteToken}`);
}
});
};
const copyLink = async () => {
if (!inviteLink) return;
await navigator.clipboard.writeText(inviteLink);
setCopied(true);
};
return (
<div className="space-y-6">
{hasPendingInvitation && (
<section className="rounded-jumbo border-warning/30 bg-warning/10 border p-5">
<p className="text-warning-foreground text-sm font-bold">Invitation pending</p>
<p className="text-muted mt-1 text-xs">
This person hasn&apos;t set a password yet. Email delivery isn&apos;t wired up — send them the link
yourself.
</p>
{inviteLink ? (
<div className="mt-3 space-y-2">
<div className="border-line bg-white break-all rounded-lg border p-3 text-xs">{inviteLink}</div>
<Button size="sm" variant="secondary" onClick={copyLink}>
{copied ? "Copied" : "Copy Link"}
</Button>
</div>
) : (
<Button size="sm" variant="secondary" className="mt-3" isLoading={isInvitePending} onClick={regenerateLink}>
Get Invite Link
</Button>
)}
</section>
)}
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Role &amp; Permissions</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Select label="Role" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
{roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Select>
{roleError && (
<p role="alert" className="text-danger text-sm font-medium">
{roleError}
</p>
)}
{roleSaved && !roleError && (
<p role="status" className="text-accent text-sm font-medium">
Saved.
</p>
)}
<Button size="sm" isLoading={isRolePending} onClick={saveRole} disabled={roleId === currentRoleId}>
Save Role
</Button>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Services Provided</h3>
<div className="rounded-jumbo border-line space-y-3 border bg-white p-6">
{allServices.map((s) => (
<Checkbox key={s.id} label={s.name} checked={serviceIds.includes(s.id)} onChange={() => toggleService(s.id)} />
))}
{serviceConflicts.length > 0 && (
<div className="border-line space-y-2 border-t pt-3">
<p role="alert" className="text-danger text-sm font-medium">
This staff member has upcoming appointments for a service you&apos;re removing:
</p>
<ul className="space-y-2">
{serviceConflicts.map((c) => (
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
</li>
))}
</ul>
<Button size="sm" variant="destructive" isLoading={isServicesPending} onClick={() => saveServices(true)}>
Save Anyway
</Button>
</div>
)}
{servicesError && serviceConflicts.length === 0 && (
<p role="alert" className="text-danger text-sm font-medium">
{servicesError}
</p>
)}
{servicesSaved && !servicesError && (
<p role="status" className="text-accent text-sm font-medium">
Saved.
</p>
)}
{serviceConflicts.length === 0 && (
<Button size="sm" isLoading={isServicesPending} onClick={() => saveServices(false)}>
Save Services
</Button>
)}
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Availability</h3>
<div className="rounded-jumbo border-line overflow-hidden border bg-white">
<Link href={`/owner/team/${staffId}/hours`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
<span className="text-sm font-bold">Working Hours</span>
<span className="text-muted text-xs">Edit</span>
</Link>
</div>
</section>
<section className="pb-2">
{status === "INACTIVE" ? (
<Button variant="secondary" className="w-full" isLoading={isDeactivatePending} onClick={reactivate}>
Reactivate Staff Member
</Button>
) : deactivateConflicts && deactivateConflicts.length > 0 ? (
<div className="border-danger/30 bg-danger/5 space-y-3 rounded-jumbo border p-5">
<p role="alert" className="text-danger text-sm font-medium">
This staff member still has {deactivateConflicts.length} upcoming appointment
{deactivateConflicts.length > 1 ? "s" : ""}. Deactivating won&apos;t cancel them — they&apos;ll need to
be reassigned or handled separately.
</p>
<ul className="space-y-2">
{deactivateConflicts.map((c) => (
<li key={c.id} className="rounded-lg border border-white bg-white p-3 text-xs">
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
</li>
))}
</ul>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={() => setDeactivateConflicts(null)}>
Go Back
</Button>
<Button variant="destructive" className="flex-1" isLoading={isDeactivatePending} onClick={() => deactivate(true)}>
Deactivate Anyway
</Button>
</div>
</div>
) : (
<>
{deactivateError && (
<p role="alert" className="text-danger mb-2 text-sm font-medium">
{deactivateError}
</p>
)}
<Button
variant="destructive"
className="w-full"
isLoading={isDeactivatePending}
onClick={() => deactivate(false)}
>
Deactivate Staff Member
</Button>
</>
)}
</section>
</div>
);
}