Phase 6
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:
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'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 & 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 & 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 & 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's open hours. They won'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}'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'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't set a password yet. Email delivery isn'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 & 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'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't cancel them — they'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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user