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,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { validateInvitationToken } from "@/services/staff/staff-invitation.service";
|
||||
import { AcceptInvitationForm } from "@/features/auth/components/accept-invitation-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Accept Invitation" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AcceptInvitationPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ token?: string }>;
|
||||
}) {
|
||||
const { token } = await searchParams;
|
||||
|
||||
if (!token) {
|
||||
return <InvalidInvitation message="This invitation link is missing its token." />;
|
||||
}
|
||||
|
||||
const check = await validateInvitationToken(prisma, token);
|
||||
if (!check.valid) {
|
||||
const message =
|
||||
check.reason === "EXPIRED"
|
||||
? "This invitation link has expired. Ask the business owner to send you a new one."
|
||||
: check.reason === "ALREADY_ACCEPTED"
|
||||
? "This invitation has already been used."
|
||||
: "This invitation link isn't valid.";
|
||||
return <InvalidInvitation message={message} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-2 text-3xl font-bold tracking-tight">Welcome, {check.invitation.staff.user.name}</h1>
|
||||
<p className="text-muted mb-10">Finish setting up your staff account.</p>
|
||||
<AcceptInvitationForm token={token} businessName={check.invitation.staff.business.name} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InvalidInvitation({ message }: { message: string }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-2 text-3xl font-bold tracking-tight">Invitation Not Available</h1>
|
||||
<p className="text-muted mb-10">{message}</p>
|
||||
<Link href="/sign-in" className="border-ink text-ink border-b font-bold text-sm">
|
||||
Go to Sign In
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { auth } from "@/auth";
|
||||
import { StaffShell } from "@/components/navigation/staff-shell";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { OWNER_NAV_ITEMS } from "@/lib/navigation/staff-nav-items";
|
||||
|
||||
/**
|
||||
* Owner is a permission on a staff account, not a separate account type
|
||||
@@ -20,5 +21,9 @@ export default async function OwnerLayout({ children }: { children: React.ReactN
|
||||
redirect("/staff");
|
||||
}
|
||||
|
||||
return <StaffShell>{children}</StaffShell>;
|
||||
return (
|
||||
<StaffShell items={OWNER_NAV_ITEMS} footerLink={{ href: "/staff", label: "Barber Tools" }}>
|
||||
{children}
|
||||
</StaffShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { Metadata } from "next";
|
||||
import { CalendarPlus, Mail, Phone } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { StatusPill } from "@/components/ui/status-pill";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
||||
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
|
||||
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
|
||||
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
|
||||
import { ownerAppointmentActions } from "@/features/owner/actions/appointment-action-set";
|
||||
import { RedeemRewardButton } from "@/features/owner/components/redeem-reward-button";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
export const metadata: Metadata = { title: "Appointment Details" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const STATUS_MAP: Record<string, AppointmentStatus> = {
|
||||
PENDING: "pending_approval",
|
||||
CONFIRMED: "confirmed",
|
||||
CHECKED_IN: "arrived",
|
||||
IN_PROGRESS: "in_progress",
|
||||
COMPLETED: "completed",
|
||||
CANCELLED: "cancelled",
|
||||
NO_SHOW: "no_show",
|
||||
};
|
||||
const RESCHEDULABLE = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
|
||||
|
||||
export default async function OwnerAppointmentDetailsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
|
||||
if (!canView) redirect("/staff");
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { id: true, timezone: true } } },
|
||||
});
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
const appointment = await getAppointmentById(prisma, id);
|
||||
if (!appointment) notFound();
|
||||
|
||||
try {
|
||||
assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.businessId });
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError) notFound();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([
|
||||
prisma.customerNote.findMany({
|
||||
where: { customerId: appointment.customerId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 5,
|
||||
include: { author: { include: { user: { select: { name: true } } } } },
|
||||
}),
|
||||
prisma.loyaltyTransaction.findMany({
|
||||
where: { appointmentId: appointment.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
getLoyaltySummary(prisma, { customerId: appointment.customerId, businessId: owner.businessId }),
|
||||
appointment.cancelledByUserId
|
||||
? prisma.user.findUnique({ where: { id: appointment.cancelledByUserId }, select: { name: true } })
|
||||
: null,
|
||||
]);
|
||||
|
||||
const tz = owner.business.timezone;
|
||||
const totalDuration = appointment.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0);
|
||||
const subtotal = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
|
||||
const discount = appointment.discountAmount ? Number(appointment.discountAmount) : 0;
|
||||
const total = subtotal - discount;
|
||||
|
||||
const canRedeemReward =
|
||||
loyalty?.availableReward &&
|
||||
appointment.discountAmount === null &&
|
||||
appointment.status !== "CANCELLED";
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Appointment Details" onBack sticky className="bg-stone" />
|
||||
|
||||
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
|
||||
<Card className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<StatusPill status={STATUS_MAP[appointment.status] ?? "confirmed"} />
|
||||
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
|
||||
Ref: {appointment.id.slice(-8)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-line space-y-1 border-t pt-4">
|
||||
<Link href={`/owner/team/${appointment.staffId}`} className="text-muted text-xs font-bold tracking-widest uppercase underline">
|
||||
{appointment.staff.user.name ?? "Staff"}
|
||||
</Link>
|
||||
<Link href={`/staff/clients/${appointment.customerId}`} className="block text-lg font-bold underline">
|
||||
{appointment.customer.firstName} {appointment.customer.lastName}
|
||||
</Link>
|
||||
<p className="text-muted text-sm">
|
||||
{appointment.customer.isGuest ? "Guest customer" : "Registered customer"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{appointment.customer.phone && (
|
||||
<a
|
||||
href={`tel:${appointment.customer.phone}`}
|
||||
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
||||
>
|
||||
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={`mailto:${appointment.customer.email}`}
|
||||
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
||||
>
|
||||
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Services</span>
|
||||
<p className="font-bold">{appointment.services.map((s) => s.service.name).join(" + ")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Date</span>
|
||||
<p className="font-bold">
|
||||
{appointment.startAt.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: tz })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Time</span>
|
||||
<p className="font-bold">
|
||||
{appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: tz })} –{" "}
|
||||
{appointment.endAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: tz })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Duration</span>
|
||||
<p className="font-bold">{totalDuration} min</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-line border-t pt-4 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted">Subtotal</span>
|
||||
<span className="font-bold">£{subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
{discount > 0 && (
|
||||
<div className="text-accent-loyalty flex items-center justify-between">
|
||||
<span>Loyalty reward</span>
|
||||
<span className="font-bold">-£{discount.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-line mt-1 flex items-center justify-between border-t pt-1">
|
||||
<span className="font-bold">Total</span>
|
||||
<span className="font-bold">£{total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{appointment.notes && (
|
||||
<div className="border-line border-t pt-4">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Booking Notes</span>
|
||||
<p className="text-muted mt-1 text-sm italic">{appointment.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notes.length > 0 && (
|
||||
<div className="border-line border-t pt-4">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Customer Notes</span>
|
||||
<div className="mt-2 space-y-2">
|
||||
{notes.map((n) => (
|
||||
<p key={n.id} className="text-muted text-xs">
|
||||
<span className="font-bold">{n.author.user.name ?? "Staff"}:</span> {n.body}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loyalty?.isEnabled && (
|
||||
<div className="border-line border-t pt-4">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Loyalty</span>
|
||||
<p className="text-sm font-bold">
|
||||
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
|
||||
{loyalty.availableReward && <span className="text-accent-loyalty"> · Reward available</span>}
|
||||
</p>
|
||||
{loyaltyTransactions.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{loyaltyTransactions.map((t) => (
|
||||
<li key={t.id} className="text-muted text-xs">
|
||||
{t.amount > 0 ? "+" : ""}
|
||||
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} — {t.type.replace(/_/g, " ").toLowerCase()}
|
||||
{t.reason ? ` (${t.reason})` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appointment.status === "CANCELLED" && (
|
||||
<div className="border-line border-t pt-4 text-sm">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Cancellation</span>
|
||||
<p className="text-muted mt-1">
|
||||
Cancelled {appointment.cancelledAt?.toLocaleString("en-GB", { timeZone: tz })}
|
||||
{cancelledByUser ? ` by ${cancelledByUser.name}` : ""}
|
||||
</p>
|
||||
{appointment.cancellationReason && (
|
||||
<p className="text-muted mt-1 italic">“{appointment.cancellationReason}”</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<a
|
||||
href={`/api/appointments/${appointment.id}/calendar.ics`}
|
||||
className="rounded-jumbo border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
||||
>
|
||||
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
|
||||
</a>
|
||||
{RESCHEDULABLE.has(appointment.status) && (
|
||||
<Link
|
||||
href={`/owner/appointments/${appointment.id}/reschedule`}
|
||||
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
|
||||
>
|
||||
Reschedule / Reassign
|
||||
</Link>
|
||||
)}
|
||||
{appointment.status === "COMPLETED" && <BookFollowUpButton appointmentId={appointment.id} />}
|
||||
</div>
|
||||
|
||||
<AppointmentStatusActions
|
||||
appointmentId={appointment.id}
|
||||
status={appointment.status}
|
||||
actions={ownerAppointmentActions}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{loyalty?.isEnabled && <AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} />}
|
||||
{canRedeemReward && loyalty.availableReward && (
|
||||
<RedeemRewardButton
|
||||
appointmentId={appointment.id}
|
||||
rewardId={loyalty.availableReward.id}
|
||||
rewardLabel={loyalty.availableReward.label}
|
||||
subtotal={subtotal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import type { Metadata } from "next";
|
||||
import { CalendarX2, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/feedback/empty-state";
|
||||
import { AppointmentCard } from "@/components/ui/appointment-card";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import {
|
||||
calendarDateToUtcMidnight,
|
||||
minutesFromMidnight,
|
||||
todayIn,
|
||||
weekdayOf,
|
||||
} from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service";
|
||||
import { listAppointments } from "@/services/scheduling/appointment.service";
|
||||
import { DateStrip } from "@/features/staff/components/date-strip";
|
||||
import { DayCalendar } from "@/features/staff/components/day-calendar";
|
||||
import type { CalendarAppointment, CalendarBlock } from "@/features/staff/components/day-calendar";
|
||||
import { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
export const metadata: Metadata = { title: "All-Staff Calendar" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const STATUS_MAP: Record<string, AppointmentStatus> = {
|
||||
PENDING: "pending_approval",
|
||||
CONFIRMED: "confirmed",
|
||||
CHECKED_IN: "arrived",
|
||||
IN_PROGRESS: "in_progress",
|
||||
COMPLETED: "completed",
|
||||
CANCELLED: "cancelled",
|
||||
NO_SHOW: "no_show",
|
||||
};
|
||||
|
||||
function formatRange(startMinutes: number, endMinutes: number): string {
|
||||
const fmt = (m: number) => {
|
||||
const h = Math.floor(m / 60);
|
||||
const min = m % 60;
|
||||
const period = h >= 12 ? "PM" : "AM";
|
||||
const displayHour = h % 12 === 0 ? 12 : h % 12;
|
||||
return `${displayHour}:${String(min).padStart(2, "0")} ${period}`;
|
||||
};
|
||||
return `${fmt(startMinutes)} – ${fmt(endMinutes)}`;
|
||||
}
|
||||
|
||||
export default async function OwnerCalendarPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ date?: string; view?: string; staffId?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in?callbackUrl=/owner/calendar");
|
||||
|
||||
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
|
||||
if (!canView) redirect("/staff");
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { id: true, name: true, timezone: true } } },
|
||||
});
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams;
|
||||
const today = todayIn(owner.business.timezone);
|
||||
const date: CalendarDate = dateParam ?? today;
|
||||
const view = viewParam === "agenda" ? "agenda" : "day";
|
||||
const weekday = weekdayOf(date);
|
||||
const dateUtcMidnight = calendarDateToUtcMidnight(date);
|
||||
const businessTimezone = owner.business.timezone;
|
||||
|
||||
const allStaff = await prisma.staffProfile.findMany({
|
||||
where: { businessId: owner.businessId, status: "ACTIVE" },
|
||||
include: { user: { select: { id: true, name: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
const selectedStaff = staffIdParam ? allStaff.find((s) => s.id === staffIdParam) : undefined;
|
||||
|
||||
const [appointments, workingHoursAll, breakDaysAll, timeOffAll] = await Promise.all([
|
||||
listAppointments(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffId: selectedStaff?.id,
|
||||
fromDate: date,
|
||||
toDate: date,
|
||||
businessTimezone,
|
||||
}),
|
||||
prisma.workingHours.findMany({
|
||||
where: { staff: { businessId: owner.businessId }, dayOfWeek: weekday },
|
||||
}),
|
||||
prisma.breakDay.findMany({
|
||||
where: { dayOfWeek: weekday, break: { staff: { businessId: owner.businessId } } },
|
||||
include: { break: true },
|
||||
}),
|
||||
prisma.timeOff.findMany({
|
||||
where: {
|
||||
staff: { businessId: owner.businessId },
|
||||
status: "APPROVED",
|
||||
startDate: { lte: dateUtcMidnight },
|
||||
endDate: { gte: dateUtcMidnight },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const rewardFlags = await getAvailableRewardFlags(prisma, {
|
||||
businessId: owner.businessId,
|
||||
customerIds: [...new Set(appointments.map((a) => a.customerId))],
|
||||
});
|
||||
|
||||
const currentTimeMinutes = date === today ? minutesFromMidnight(new Date(), businessTimezone) : null;
|
||||
|
||||
const staffToRender = selectedStaff ? [selectedStaff] : allStaff;
|
||||
|
||||
function buildColumnFor(staffId: string) {
|
||||
const workingHours = workingHoursAll.find((h) => h.staffId === staffId);
|
||||
const workingWindow =
|
||||
workingHours && !workingHours.isOff && workingHours.startMinutes !== null && workingHours.endMinutes !== null
|
||||
? { startMinutes: workingHours.startMinutes, endMinutes: workingHours.endMinutes }
|
||||
: null;
|
||||
|
||||
const blocks: CalendarBlock[] = [
|
||||
...breakDaysAll
|
||||
.filter((bd) => bd.break.staffId === staffId)
|
||||
.map((bd) => ({
|
||||
id: `break-${bd.id}`,
|
||||
startMinutes: bd.break.startMinutes,
|
||||
endMinutes: bd.break.endMinutes,
|
||||
label: bd.break.label,
|
||||
kind: "break" as const,
|
||||
})),
|
||||
...timeOffAll
|
||||
.filter((t) => t.staffId === staffId && t.startMinutes !== null && t.endMinutes !== null)
|
||||
.map((t) => ({
|
||||
id: `timeoff-${t.id}`,
|
||||
startMinutes: t.startMinutes!,
|
||||
endMinutes: t.endMinutes!,
|
||||
label: t.reason ?? "Blocked",
|
||||
kind: "timeOff" as const,
|
||||
})),
|
||||
];
|
||||
|
||||
const wholeDayOff = timeOffAll.some((t) => t.staffId === staffId && t.startMinutes === null);
|
||||
|
||||
const staffAppointments: CalendarAppointment[] = appointments
|
||||
.filter((a) => a.staffId === staffId)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
startMinutes: minutesFromMidnight(a.startAt, businessTimezone),
|
||||
endMinutes: minutesFromMidnight(a.endAt, businessTimezone),
|
||||
customerName: `${a.customer.firstName} ${a.customer.lastName}`,
|
||||
serviceNames: a.services.map((s) => s.service.name).join(" + "),
|
||||
status: STATUS_MAP[a.status] ?? "confirmed",
|
||||
hasNotes: !!a.notes,
|
||||
hasAvailableReward: rewardFlags.get(a.customerId) ?? false,
|
||||
}));
|
||||
|
||||
return { workingWindow, blocks, wholeDayOff, staffAppointments };
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-6">
|
||||
<div className="flex items-center justify-between px-6 pt-12">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Calendar</h1>
|
||||
<div className="flex gap-2">
|
||||
{selectedStaff && <OwnerBlockTimeButton staffId={selectedStaff.id} staffName={selectedStaff.user.name ?? "Staff"} date={date} />}
|
||||
<Button asChild size="sm">
|
||||
{/* /staff/book already lets whoever is signed in book with any eligible barber — the
|
||||
owner is a staff member too, so this is the same "book for a customer" flow Phase 5
|
||||
built, not a duplicate. See docs/phase-6-summary.md, "Owner appointment access". */}
|
||||
<Link href="/staff/book">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
New
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DateStrip
|
||||
selectedDate={date}
|
||||
businessTimezone={businessTimezone}
|
||||
basePath="/owner/calendar"
|
||||
view={view}
|
||||
extraParams={selectedStaff ? { staffId: selectedStaff.id } : undefined}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 px-6">
|
||||
<Link
|
||||
href={`/owner/calendar?date=${date}&view=${view}`}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
!selectedStaff ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
All Staff
|
||||
</Link>
|
||||
{allStaff.map((s) => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/owner/calendar?date=${date}&view=${view}&staffId=${s.id}`}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
selectedStaff?.id === s.id ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
{s.user.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2 px-6">
|
||||
<Link
|
||||
href={`/owner/calendar?date=${date}&view=day${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
view === "day" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
Day
|
||||
</Link>
|
||||
<Link
|
||||
href={`/owner/calendar?date=${date}&view=agenda${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
view === "agenda" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
Agenda
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{view === "day" ? (
|
||||
staffToRender.length === 0 ? (
|
||||
<div className="px-6">
|
||||
<EmptyState icon={CalendarX2} title="No active staff" description="Add a staff member from Team." />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-4 overflow-x-auto px-6 pb-4 lg:px-0">
|
||||
{staffToRender.map((s) => {
|
||||
const column = buildColumnFor(s.id);
|
||||
return (
|
||||
<div key={s.id} className="w-[320px] shrink-0 lg:w-auto lg:flex-1">
|
||||
<p className="text-muted mb-2 px-6 text-xs font-bold tracking-widest uppercase lg:px-0">
|
||||
{s.user.name}
|
||||
</p>
|
||||
{column.wholeDayOff ? (
|
||||
<div className="px-6 lg:px-0">
|
||||
<EmptyState icon={CalendarX2} title="Off this day" />
|
||||
</div>
|
||||
) : (
|
||||
<DayCalendar
|
||||
appointments={column.staffAppointments}
|
||||
blocks={column.blocks}
|
||||
workingWindow={column.workingWindow}
|
||||
currentTimeMinutes={currentTimeMinutes}
|
||||
appointmentHrefBase="/owner/appointments"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : appointments.length === 0 ? (
|
||||
<div className="px-6">
|
||||
<EmptyState icon={CalendarX2} title="No appointments" description="Nothing scheduled for this day." />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 px-6 lg:mx-auto lg:max-w-3xl">
|
||||
{[...appointments]
|
||||
.sort((a, b) => a.startAt.getTime() - b.startAt.getTime())
|
||||
.map((a) => (
|
||||
<div key={a.id}>
|
||||
{!selectedStaff && (
|
||||
<p className="text-muted mb-1 text-[10px] font-bold tracking-widest uppercase">
|
||||
{a.staff.user.name}
|
||||
</p>
|
||||
)}
|
||||
<AppointmentCard
|
||||
status={STATUS_MAP[a.status] ?? "confirmed"}
|
||||
serviceName={a.services.map((s) => s.service.name).join(" + ")}
|
||||
person={{ id: a.customerId, name: `${a.customer.firstName} ${a.customer.lastName}` }}
|
||||
personLabel="for"
|
||||
formattedTime={formatRange(
|
||||
minutesFromMidnight(a.startAt, businessTimezone),
|
||||
minutesFromMidnight(a.endAt, businessTimezone),
|
||||
)}
|
||||
hasNotes={!!a.notes}
|
||||
hasAvailableReward={rewardFlags.get(a.customerId) ?? false}
|
||||
actions={
|
||||
<Link
|
||||
href={`/owner/appointments/${a.id}`}
|
||||
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,189 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ChartLine } from "lucide-react";
|
||||
import { ComingSoon } from "@/components/feedback/coming-soon";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
CalendarX2,
|
||||
CheckCheck,
|
||||
Gift,
|
||||
Stamp,
|
||||
TrendingUp,
|
||||
UserX,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { todayIn } from "@/lib/time/business-time";
|
||||
import { getDashboardSummary } from "@/services/reporting/owner-reporting.service";
|
||||
|
||||
export const metadata: Metadata = { title: "Business Dashboard" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function OwnerDashboardPage() {
|
||||
return <ComingSoon icon={ChartLine} title="Business Dashboard" phase="Phase 4: Owner Administration" />;
|
||||
function StatTile({ icon: Icon, label, value, hint }: { icon: typeof Calendar; label: string; value: string | number; hint?: string }) {
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<div className="text-muted mb-2 flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="text-[10px] font-bold tracking-widest uppercase">{label}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
{hint && <p className="text-muted mt-1 text-xs">{hint}</p>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function OwnerDashboardPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in?callbackUrl=/owner");
|
||||
|
||||
const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING);
|
||||
if (!canView) redirect("/staff");
|
||||
|
||||
const staff = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { id: true, name: true, timezone: true } } },
|
||||
});
|
||||
if (!staff) redirect("/staff");
|
||||
|
||||
const today = todayIn(staff.business.timezone);
|
||||
const summary = await getDashboardSummary(prisma, {
|
||||
businessId: staff.businessId,
|
||||
businessTimezone: staff.business.timezone,
|
||||
date: today,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-6 pt-12 pb-12">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="text-muted text-sm">{staff.business.name} — today</p>
|
||||
</div>
|
||||
|
||||
{summary.warnings.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
{summary.warnings.map((w, i) => (
|
||||
<div
|
||||
key={`${w.code}-${i}`}
|
||||
role="alert"
|
||||
className="bg-warning/10 text-warning-foreground border-warning/30 flex items-start gap-3 rounded-jumbo border p-4 text-sm"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<p>{w.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatTile icon={Calendar} label="Today's appointments" value={summary.totals.total} />
|
||||
<StatTile icon={CheckCheck} label="Completed" value={summary.totals.completed} />
|
||||
<StatTile icon={CalendarX2} label="Cancelled" value={summary.totals.cancelled} />
|
||||
<StatTile icon={UserX} label="No-shows" value={summary.totals.noShow} />
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatTile icon={TrendingUp} label="In progress now" value={summary.totals.inProgress} />
|
||||
<StatTile icon={Calendar} label="Still to come today" value={summary.totals.upcoming} />
|
||||
<StatTile
|
||||
icon={TrendingUp}
|
||||
label="Estimated occupancy"
|
||||
value={summary.occupancy ? `${summary.occupancy.percent}%` : "—"}
|
||||
hint={summary.occupancy ? "Booked vs. working minutes today — an estimate, not a financial figure." : "No staff working today."}
|
||||
/>
|
||||
<StatTile icon={Gift} label="Customers with a reward" value={summary.customersWithAvailableRewards} />
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-2">
|
||||
<Card className="p-5">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||
<Users className="text-accent h-4 w-4" aria-hidden="true" /> Staff Today
|
||||
</h2>
|
||||
{summary.staffWorkingToday.length === 0 ? (
|
||||
<p className="text-muted text-sm">No one is scheduled to work today.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{summary.staffWorkingToday.map((s) => (
|
||||
<li key={s.staffId} className="flex items-center justify-between text-sm">
|
||||
<span className="font-bold">{s.name}</span>
|
||||
<span className="text-accent text-xs font-bold tracking-widest uppercase">Working</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{summary.staffOnTimeOffToday.length > 0 && (
|
||||
<ul className="border-line mt-3 space-y-2 border-t pt-3">
|
||||
{summary.staffOnTimeOffToday.map((s) => (
|
||||
<li key={s.staffId} className="flex items-center justify-between text-sm">
|
||||
<span className="font-bold">{s.name}</span>
|
||||
<span className="text-muted text-xs font-bold tracking-widest uppercase">
|
||||
{s.reason ?? "Time off"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-5">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||
<TrendingUp className="text-accent h-4 w-4" aria-hidden="true" /> Popular Services
|
||||
</h2>
|
||||
<p className="text-muted mb-3 text-xs">Completed visits, last 30 days.</p>
|
||||
{summary.popularServices.length === 0 ? (
|
||||
<p className="text-muted text-sm">No completed visits in the last 30 days yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{summary.popularServices.map((s) => (
|
||||
<li key={s.serviceId} className="flex items-center justify-between text-sm">
|
||||
<span className="font-bold">{s.name}</span>
|
||||
<span className="text-muted">{s.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card className="p-5">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||
<Stamp className="text-accent h-4 w-4" aria-hidden="true" /> Recent Loyalty Activity
|
||||
</h2>
|
||||
{summary.recentLoyaltyActivity.length === 0 ? (
|
||||
<p className="text-muted text-sm">No loyalty activity yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{summary.recentLoyaltyActivity.map((t) => (
|
||||
<li key={t.id} className="text-muted text-xs">
|
||||
<span className="text-ink font-bold">
|
||||
{t.loyaltyAccount.customer.firstName} {t.loyaltyAccount.customer.lastName}
|
||||
</span>{" "}
|
||||
— {t.amount > 0 ? "+" : ""}
|
||||
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} ({t.type.replace(/_/g, " ").toLowerCase()})
|
||||
{t.authoredByStaff?.user.name ? ` · ${t.authoredByStaff.user.name}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/owner/team"
|
||||
className="rounded-jumbo border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
|
||||
>
|
||||
Manage Team
|
||||
</Link>
|
||||
<Link
|
||||
href="/owner/settings/booking-rules"
|
||||
className="rounded-jumbo bg-ink flex-1 px-5 py-4 text-center text-sm font-bold text-white"
|
||||
>
|
||||
Update Booking Rules
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { listCategories } from "@/services/catalogue/service-catalogue.service";
|
||||
import { CatalogueManager } from "@/features/owner/components/catalogue-manager";
|
||||
|
||||
export const metadata: Metadata = { title: "Services" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerServicesPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_SERVICES);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const categories = await listCategories(prisma, owner.businessId);
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-12 pb-12 lg:max-w-3xl">
|
||||
<h1 className="mb-6 text-2xl font-bold tracking-tight">Services</h1>
|
||||
<CatalogueManager
|
||||
categories={categories.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
isActive: c.isActive,
|
||||
services: c.services.map((s) => ({
|
||||
id: s.id,
|
||||
categoryId: s.categoryId,
|
||||
name: s.name,
|
||||
description: s.description ?? "",
|
||||
durationMinutes: s.durationMinutes,
|
||||
price: s.price.toString(),
|
||||
bufferBeforeMinutes: s.bufferBeforeMinutes,
|
||||
bufferAfterMinutes: s.bufferAfterMinutes,
|
||||
isActive: s.isActive,
|
||||
allowOnlineBooking: s.allowOnlineBooking,
|
||||
})),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getBookingRule } from "@/services/scheduling/booking-rule.service";
|
||||
import { BookingRulesForm } from "@/features/owner/components/booking-rules-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Booking Rules" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerBookingRulesPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BOOKING_RULES);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const rule = await getBookingRule(prisma, owner.businessId);
|
||||
if (!rule) redirect("/owner");
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Booking Rules" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
|
||||
<BookingRulesForm
|
||||
initial={{
|
||||
minNoticeMinutes: rule.minNoticeMinutes,
|
||||
maxAdvanceDays: rule.maxAdvanceDays,
|
||||
cancellationWindowHours: rule.cancellationWindowHours,
|
||||
rescheduleWindowHours: rule.rescheduleWindowHours,
|
||||
slotIntervalMinutes: rule.slotIntervalMinutes,
|
||||
bufferBeforeMinutes: rule.bufferBeforeMinutes,
|
||||
bufferAfterMinutes: rule.bufferAfterMinutes,
|
||||
maxAppointmentsPerDay: rule.maxAppointmentsPerDay,
|
||||
maxAppointmentsPerStaffPerDay: rule.maxAppointmentsPerStaffPerDay,
|
||||
customerCanChooseStaff: rule.customerCanChooseStaff,
|
||||
allowGuestBooking: rule.allowGuestBooking,
|
||||
requireApproval: rule.requireApproval,
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getBusinessProfile } from "@/services/business/business.service";
|
||||
import { BusinessDetailsForm } from "@/features/owner/components/business-details-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Business Details" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerBusinessDetailsPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!staff) redirect("/staff");
|
||||
|
||||
const business = await getBusinessProfile(prisma, staff.businessId);
|
||||
if (!business) redirect("/owner");
|
||||
|
||||
const location = business.locations[0];
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Business Details" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
|
||||
<BusinessDetailsForm
|
||||
initial={{
|
||||
name: business.name,
|
||||
description: business.description ?? "",
|
||||
logoUrl: business.logoUrl ?? "",
|
||||
phone: business.phone ?? "",
|
||||
email: business.email ?? "",
|
||||
website: business.website ?? "",
|
||||
instagramUrl: business.instagramUrl ?? "",
|
||||
facebookUrl: business.facebookUrl ?? "",
|
||||
timezone: business.timezone,
|
||||
currency: business.currency,
|
||||
locale: business.locale,
|
||||
addressLine1: location?.addressLine1 ?? "",
|
||||
addressLine2: location?.addressLine2 ?? "",
|
||||
city: location?.city ?? "",
|
||||
region: location?.region ?? "",
|
||||
postalCode: location?.postalCode ?? "",
|
||||
country: location?.country ?? "GB",
|
||||
cancellationPolicy: business.settings?.cancellationPolicy ?? "",
|
||||
lateArrivalPolicy: business.settings?.lateArrivalPolicy ?? "",
|
||||
bookingTerms: business.settings?.bookingTerms ?? "",
|
||||
privacyPolicyUrl: business.settings?.privacyPolicyUrl ?? "",
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getOpeningHours } from "@/services/business/business.service";
|
||||
import { OpeningHoursForm } from "@/features/owner/components/opening-hours-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Opening Hours" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerOpeningHoursPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!staff) redirect("/staff");
|
||||
|
||||
const hours = await getOpeningHours(prisma, staff.businessId);
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Opening Hours" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<OpeningHoursForm initialRows={hours} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getProgrammeConfig } from "@/services/loyalty/loyalty.service";
|
||||
import { LoyaltyConfigForm } from "@/features/owner/components/loyalty-config-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Loyalty Programme" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerLoyaltyConfigPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const [programme, services, staff] = await Promise.all([
|
||||
getProgrammeConfig(prisma, owner.businessId),
|
||||
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
||||
prisma.staffProfile.findMany({
|
||||
where: { businessId: owner.businessId, status: "ACTIVE" },
|
||||
include: { user: { select: { name: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const initialConfig = programme
|
||||
? {
|
||||
isEnabled: programme.isEnabled,
|
||||
stampsRequired: programme.stampsRequired,
|
||||
rewardType: programme.rewardType,
|
||||
rewardValue: programme.rewardValue?.toString(),
|
||||
rewardLabel: programme.rewardLabel ?? undefined,
|
||||
maxRewardValue: programme.maxRewardValue?.toString(),
|
||||
freeServiceId: programme.freeServiceId ?? undefined,
|
||||
autoApplyReward: programme.autoApplyReward,
|
||||
requiresStaffApproval: programme.requiresStaffApproval,
|
||||
stampsExpireAfterDays: programme.stampsExpireAfterDays ?? undefined,
|
||||
rewardExpiresAfterDays: programme.rewardExpiresAfterDays ?? undefined,
|
||||
earnOnCancelled: programme.earnOnCancelled,
|
||||
earnOnNoShow: programme.earnOnNoShow,
|
||||
cardBackgroundColor: programme.cardBackgroundColor ?? undefined,
|
||||
cardBackgroundImageUrl: programme.cardBackgroundImageUrl ?? undefined,
|
||||
stampIcon: programme.stampIcon ?? undefined,
|
||||
termsLine: programme.termsLine ?? undefined,
|
||||
}
|
||||
: {
|
||||
isEnabled: false,
|
||||
stampsRequired: 10,
|
||||
rewardType: "PERCENTAGE" as const,
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Loyalty Programme" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
|
||||
<LoyaltyConfigForm
|
||||
initialConfig={initialConfig}
|
||||
initialEligibleServiceIds={programme?.eligibleServices.map((s) => s.serviceId) ?? []}
|
||||
initialEligibleStaffIds={programme?.eligibleStaff.map((s) => s.staffId) ?? []}
|
||||
services={services.map((s) => ({ id: s.id, name: s.name }))}
|
||||
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff" }))}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Bell, Building2, CalendarClock, ClipboardList, Crown, ScissorsSquare } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { ProfileCard } from "@/components/ui/profile-card";
|
||||
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
|
||||
export const metadata: Metadata = { title: "Owner Settings" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const SETTINGS_LINKS = [
|
||||
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
|
||||
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
|
||||
{ href: "/owner/settings/booking-rules", label: "Booking Rules", icon: ClipboardList },
|
||||
{ href: "/owner/settings/loyalty", label: "Loyalty Programme", icon: Crown },
|
||||
{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell },
|
||||
] as const;
|
||||
|
||||
export default async function OwnerSettingsPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-6 pt-12 pb-12">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Owner Settings</h1>
|
||||
<ProfileCard name={session.user.name ?? "Owner"} meta={session.user.email} avatarUrl={session.user.image ?? undefined} />
|
||||
|
||||
<Link
|
||||
href="/staff/settings"
|
||||
className="rounded-jumbo border-line flex items-center gap-3 border bg-white p-5 text-sm font-bold hover:bg-stone"
|
||||
>
|
||||
<ScissorsSquare className="text-accent h-5 w-5" aria-hidden="true" />
|
||||
Switch to Barber Tools
|
||||
</Link>
|
||||
|
||||
<ul className="divide-line rounded-jumbo border-line divide-y border bg-white">
|
||||
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
|
||||
<li key={href}>
|
||||
<Link href={href} className="hover:bg-stone flex items-center gap-3 px-5 py-4 text-sm font-bold">
|
||||
<Icon className="text-muted h-5 w-5" aria-hidden="true" />
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<SignOutButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission, Weekday } from "@/generated/prisma/enums";
|
||||
import { getWorkingHours } from "@/services/staff/staff.service";
|
||||
import { WorkingHoursForm } from "@/features/staff/components/working-hours-form";
|
||||
import { updateStaffWorkingHoursAction } from "@/features/owner/actions/staff";
|
||||
|
||||
export const metadata: Metadata = { title: "Staff Working Hours" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DAY_ORDER: Weekday[] = [
|
||||
Weekday.MONDAY,
|
||||
Weekday.TUESDAY,
|
||||
Weekday.WEDNESDAY,
|
||||
Weekday.THURSDAY,
|
||||
Weekday.FRIDAY,
|
||||
Weekday.SATURDAY,
|
||||
Weekday.SUNDAY,
|
||||
];
|
||||
|
||||
function formatMinutes(m: number): string {
|
||||
const h = Math.floor(m / 60);
|
||||
const min = m % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default async function OwnerStaffWorkingHoursPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
const target = await prisma.staffProfile.findFirst({
|
||||
where: { id, businessId: owner.businessId },
|
||||
include: { user: { select: { name: true } } },
|
||||
});
|
||||
if (!target) notFound();
|
||||
|
||||
const [workingHours, businessHours] = await Promise.all([
|
||||
getWorkingHours(prisma, id),
|
||||
prisma.businessHours.findMany({ where: { businessId: owner.businessId } }),
|
||||
]);
|
||||
|
||||
const businessHoursLabel = Object.fromEntries(
|
||||
DAY_ORDER.map((day) => {
|
||||
const hours = businessHours.find((h) => h.dayOfWeek === day);
|
||||
const label =
|
||||
!hours || hours.isClosed || hours.opensAtMinutes === null || hours.closesAtMinutes === null
|
||||
? "Closed"
|
||||
: `${formatMinutes(hours.opensAtMinutes)}–${formatMinutes(hours.closesAtMinutes)}`;
|
||||
return [day, label];
|
||||
}),
|
||||
) as Record<Weekday, string>;
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title={`${target.user.name ?? "Staff"} — Working Hours`} onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<WorkingHoursForm
|
||||
initialRows={workingHours}
|
||||
businessHoursLabel={businessHoursLabel}
|
||||
onSave={(days) => updateStaffWorkingHoursAction(id, days)}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Mail, Phone } from "lucide-react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getStaffDetailById, listRolesForBusiness } from "@/services/staff/staff.service";
|
||||
import { StaffDetailPanel } from "@/features/owner/components/staff-detail-panel";
|
||||
|
||||
export const metadata: Metadata = { title: "Staff Details" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerStaffDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
const [target, roles, allServices] = await Promise.all([
|
||||
getStaffDetailById(prisma, { staffId: id, businessId: owner.businessId }),
|
||||
listRolesForBusiness(prisma, owner.businessId),
|
||||
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
||||
]);
|
||||
if (!target) notFound();
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Staff Details" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6 lg:max-w-4xl lg:grid lg:max-w-none lg:grid-cols-[320px_1fr] lg:items-start lg:gap-6 lg:space-y-0">
|
||||
<div className="space-y-4">
|
||||
<Card className="flex flex-col items-center p-6 text-center">
|
||||
{target.user.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- external avatar URL, no next/image domain config for it
|
||||
<img src={target.user.image} alt="" className="border-stone mb-4 h-24 w-24 rounded-full border-4 object-cover" />
|
||||
) : (
|
||||
<div className="bg-stone text-muted mb-4 flex h-24 w-24 items-center justify-center rounded-full text-2xl font-bold">
|
||||
{(target.user.name ?? "?").charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-xl font-bold">{target.user.name}</h2>
|
||||
<p className="text-accent mt-1 text-xs font-bold tracking-widest uppercase">
|
||||
{target.role.name} · {target.status === "ACTIVE" ? "Active" : target.status === "ON_BREAK" ? "On Break" : "Inactive"}
|
||||
</p>
|
||||
<p className="text-muted mt-2 text-xs">Joined {target.createdAt.toLocaleDateString("en-GB", { month: "short", year: "numeric" })}</p>
|
||||
{target.bio && <p className="text-muted mt-3 text-sm">{target.bio}</p>}
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{target.user.phone && (
|
||||
<a href={`tel:${target.user.phone}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
|
||||
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Call
|
||||
</a>
|
||||
)}
|
||||
{target.user.email && (
|
||||
<a href={`mailto:${target.user.email}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
|
||||
<Mail className="text-accent h-4 w-4" aria-hidden="true" /> Email
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StaffDetailPanel
|
||||
staffId={target.id}
|
||||
currentRoleId={target.roleId}
|
||||
roles={roles.map((r) => ({ id: r.id, name: r.name }))}
|
||||
allServices={allServices.map((s) => ({ id: s.id, name: s.name }))}
|
||||
initialServiceIds={target.services.map((s) => s.serviceId)}
|
||||
status={target.status}
|
||||
hasPendingInvitation={!!target.invitation && !target.invitation.acceptedAt}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { listRolesForBusiness } from "@/services/staff/staff.service";
|
||||
import { AddStaffForm } from "@/features/owner/components/add-staff-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Add Staff" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerAddStaffPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const [roles, services] = await Promise.all([
|
||||
listRolesForBusiness(prisma, owner.businessId),
|
||||
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Add Staff" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<AddStaffForm roles={roles.map((r) => ({ id: r.id, name: r.name }))} services={services.map((s) => ({ id: s.id, name: s.name }))} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,95 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Users } from "lucide-react";
|
||||
import { ComingSoon } from "@/components/feedback/coming-soon";
|
||||
import Link from "next/link";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { listStaffForBusiness } from "@/services/staff/staff.service";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
export const metadata: Metadata = { title: "Team Management" };
|
||||
export const metadata: Metadata = { title: "Team" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function OwnerTeamPage() {
|
||||
return <ComingSoon icon={Users} title="Team Management" phase="Phase 4: Owner Administration" />;
|
||||
export default async function OwnerTeamPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const { q = "" } = await searchParams;
|
||||
const staff = await listStaffForBusiness(prisma, { businessId: owner.businessId, q, includeInactive: true });
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-12 pb-12 lg:max-w-4xl">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Team</h1>
|
||||
<Link
|
||||
href="/owner/team/add"
|
||||
className="bg-ink flex h-10 w-10 items-center justify-center rounded-full text-white"
|
||||
aria-label="Add staff member"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<form action="/owner/team" method="GET" className="relative mb-4">
|
||||
<Search className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2" aria-hidden="true" />
|
||||
<label htmlFor="team-search" className="sr-only">
|
||||
Search staff by name or email
|
||||
</label>
|
||||
<input
|
||||
id="team-search"
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Search by name or email"
|
||||
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-3 pr-4 pl-11 text-sm outline-none focus:ring-2"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{staff.length === 0 ? (
|
||||
<p className="text-muted col-span-full rounded-jumbo border border-line bg-white p-5 text-center text-sm">
|
||||
No staff match “{q}”.
|
||||
</p>
|
||||
) : (
|
||||
staff.map((s) => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/owner/team/${s.id}`}
|
||||
className={cn(
|
||||
"rounded-jumbo border-line flex items-center gap-4 border bg-white p-5",
|
||||
s.status === "INACTIVE" && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="bg-stone text-muted flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-sm font-bold">
|
||||
{(s.user.name ?? "?").charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold">{s.user.name}</p>
|
||||
<p className="text-muted truncate text-xs">{s.user.email}</p>
|
||||
<p className="text-accent mt-0.5 text-[10px] font-bold tracking-widest uppercase">
|
||||
{s.role.name}
|
||||
{s.status === "INACTIVE" && <span className="text-muted"> · Inactive</span>}
|
||||
{s.status === "ACTIVE" && s.invitation && !s.invitation.acceptedAt && (
|
||||
<span className="text-warning-foreground"> · Invite Pending</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/sta
|
||||
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
|
||||
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
|
||||
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
|
||||
import { RedeemRewardButton } from "@/features/owner/components/redeem-reward-button";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
export const metadata: Metadata = { title: "Appointment Details" };
|
||||
@@ -74,6 +75,9 @@ export default async function StaffAppointmentDetailsPage({
|
||||
0,
|
||||
);
|
||||
const totalPrice = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
|
||||
const discount = appointment.discountAmount ? Number(appointment.discountAmount) : 0;
|
||||
const canRedeemReward =
|
||||
loyalty?.availableReward && appointment.discountAmount === null && appointment.status !== "CANCELLED";
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
@@ -163,6 +167,9 @@ export default async function StaffAppointmentDetailsPage({
|
||||
</span>
|
||||
<p className="font-bold">
|
||||
{totalDuration} min · £{totalPrice.toFixed(2)}
|
||||
{discount > 0 && (
|
||||
<span className="text-accent-loyalty"> (-£{discount.toFixed(2)} reward)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,9 +264,19 @@ export default async function StaffAppointmentDetailsPage({
|
||||
|
||||
<AppointmentStatusActions appointmentId={appointment.id} status={appointment.status} />
|
||||
|
||||
{loyalty?.isEnabled && (
|
||||
<AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} />
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{loyalty?.isEnabled && (
|
||||
<AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} />
|
||||
)}
|
||||
{canRedeemReward && loyalty?.availableReward && (
|
||||
<RedeemRewardButton
|
||||
appointmentId={appointment.id}
|
||||
rewardId={loyalty.availableReward.id}
|
||||
rewardLabel={loyalty.availableReward.label}
|
||||
subtotal={totalPrice}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Deliberately outside the (owner) route group, same reasoning as
|
||||
* src/app/staff/appointments/[id]/reschedule/layout.tsx: this page reuses
|
||||
* BarberDateTimePicker, whose own fixed bottom action bar would otherwise
|
||||
* sit under the persistent owner sidebar/bottom-nav and swallow clicks
|
||||
* meant for "Confirm Reschedule".
|
||||
*/
|
||||
export default function OwnerRescheduleLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="bg-stone min-h-screen">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
||||
import { ownerRescheduleAppointmentAction } from "@/features/owner/actions/appointments";
|
||||
import { BarberDateTimePicker } from "@/features/booking/components/barber-date-time-picker";
|
||||
|
||||
export const metadata: Metadata = { title: "Reschedule / Reassign" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const NON_RESCHEDULABLE = new Set(["COMPLETED", "CANCELLED", "NO_SHOW", "IN_PROGRESS"]);
|
||||
|
||||
/**
|
||||
* Also the owner's appointment-reassignment surface — BarberDateTimePicker
|
||||
* already lets the caller choose a different eligible barber as part of
|
||||
* picking a new slot, so "reassign to another barber" and "reschedule to a
|
||||
* new time" are the same underlying operation here, not two separate
|
||||
* flows. See docs/phase-6-summary.md, "Appointment reassignment approach".
|
||||
*/
|
||||
export default async function OwnerRescheduleAppointmentPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_ALL_APPOINTMENTS);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { id: true, timezone: true } } },
|
||||
});
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
const appointment = await getAppointmentById(prisma, id);
|
||||
if (!appointment) notFound();
|
||||
|
||||
try {
|
||||
assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.businessId });
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError) notFound();
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (NON_RESCHEDULABLE.has(appointment.status)) {
|
||||
redirect(`/owner/appointments/${id}`);
|
||||
}
|
||||
|
||||
async function handleContinue(selection: { staffId?: string; date: string; startAt: string }) {
|
||||
"use server";
|
||||
const result = await ownerRescheduleAppointmentAction({
|
||||
appointmentId: id,
|
||||
staffId: selection.staffId,
|
||||
startAt: selection.startAt,
|
||||
});
|
||||
if (result.success) {
|
||||
redirect(`/owner/appointments/${id}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-6">
|
||||
<TopNav title="Reschedule / Reassign" onBack sticky className="bg-stone" />
|
||||
|
||||
<Card className="mx-6 mt-4 flex items-center gap-3 p-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-bold">
|
||||
{appointment.services.map((s) => s.service.name).join(" + ")} for {appointment.customer.firstName}{" "}
|
||||
{appointment.customer.lastName}
|
||||
</p>
|
||||
<p className="text-muted text-xs">
|
||||
Currently with {appointment.staff.user.name} —{" "}
|
||||
{appointment.startAt.toLocaleDateString("en-GB", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: owner.business.timezone,
|
||||
})}{" "}
|
||||
·{" "}
|
||||
{appointment.startAt.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: owner.business.timezone,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mt-4">
|
||||
<BarberDateTimePicker
|
||||
businessId={owner.businessId}
|
||||
businessTimezone={owner.business.timezone}
|
||||
serviceIds={appointment.services.map((s) => s.serviceId)}
|
||||
initialStaffId={appointment.staffId}
|
||||
onContinue={handleContinue}
|
||||
continueLabel="Confirm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { CalendarCheck, CalendarDays, House, Scissors, Settings, User, Users } from "lucide-react";
|
||||
import { CalendarCheck, CalendarDays, ChartLine, House, Scissors, Settings, User, Users } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
@@ -23,6 +23,7 @@ export const NAV_ICON_MAP = {
|
||||
scissors: Scissors,
|
||||
users: Users,
|
||||
settings: Settings,
|
||||
"chart-line": ChartLine,
|
||||
} satisfies Record<string, LucideIcon>;
|
||||
|
||||
export type BottomNavIconName = keyof typeof NAV_ICON_MAP;
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import { BottomNav } from "./bottom-nav";
|
||||
import { StaffSidebarNav } from "./staff-sidebar-nav";
|
||||
import type { BottomNavItem } from "./bottom-nav";
|
||||
import { STAFF_NAV_ITEMS } from "@/lib/navigation/staff-nav-items";
|
||||
|
||||
/** Shared by (staff) and (owner) — see docs/implementation-plan.md §3: owner is a permission on a staff account, not a separate shell. */
|
||||
export function StaffShell({ children }: { children: React.ReactNode }) {
|
||||
/**
|
||||
* Shared by (staff) and (owner) — see docs/implementation-plan.md §3: owner
|
||||
* is a permission on a staff account, not a separate shell. `items`
|
||||
* defaults to the staff nav; (owner) passes OWNER_NAV_ITEMS instead (see
|
||||
* staff-nav-items.ts) — same shell, same sidebar/bottom-nav components,
|
||||
* different item set, per the Phase 6 brief's own nav requirement.
|
||||
*/
|
||||
export function StaffShell({
|
||||
children,
|
||||
items = STAFF_NAV_ITEMS,
|
||||
footerLink,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
items?: BottomNavItem[];
|
||||
footerLink?: { href: string; label: string };
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone pb-24 lg:pb-0 lg:pl-56">
|
||||
<StaffSidebarNav items={STAFF_NAV_ITEMS} />
|
||||
<StaffSidebarNav items={items} footerLink={footerLink} />
|
||||
{children}
|
||||
<BottomNav items={STAFF_NAV_ITEMS} variant="staff" />
|
||||
<BottomNav items={items} variant="staff" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Scissors } from "lucide-react";
|
||||
import { ArrowLeftRight, Scissors } from "lucide-react";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { NAV_ICON_MAP } from "./bottom-nav";
|
||||
import type { BottomNavItem } from "./bottom-nav";
|
||||
@@ -15,7 +15,14 @@ import type { BottomNavItem } from "./bottom-nav";
|
||||
* to spare, per the brief's explicit call-out that staff desktop needs
|
||||
* more than the mobile bar simply centered.
|
||||
*/
|
||||
export function StaffSidebarNav({ items }: { items: BottomNavItem[] }) {
|
||||
export function StaffSidebarNav({
|
||||
items,
|
||||
footerLink,
|
||||
}: {
|
||||
items: BottomNavItem[];
|
||||
/** Owner sidebar's explicit "back to barber tools" — see the brief's "clear access back to barber tools". Omitted on the staff sidebar itself. */
|
||||
footerLink?: { href: string; label: string };
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
@@ -27,7 +34,7 @@ export function StaffSidebarNav({ items }: { items: BottomNavItem[] }) {
|
||||
<Scissors className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="text-sm font-bold tracking-widest uppercase">NextChair</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
{items.map(({ href, label, icon }) => {
|
||||
const Icon = NAV_ICON_MAP[icon];
|
||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
||||
@@ -47,6 +54,15 @@ export function StaffSidebarNav({ items }: { items: BottomNavItem[] }) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{footerLink && (
|
||||
<Link
|
||||
href={footerLink.href}
|
||||
className="mt-4 flex items-center gap-3 rounded-xl border-t border-white/5 px-3 pt-4 text-sm font-bold text-gray-400 transition-colors hover:text-white"
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4" aria-hidden="true" />
|
||||
{footerLink.label}
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use server";
|
||||
|
||||
import { signIn } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { acceptInvitation, validateInvitationToken } from "@/services/staff/staff-invitation.service";
|
||||
import { acceptInvitationSchema } from "@/features/auth/schemas";
|
||||
|
||||
export interface AcceptInvitationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string[] | undefined>;
|
||||
}
|
||||
|
||||
/** Public — the invited person has no account/session yet. Mirrors registerAction's shape: create the credential, then sign in immediately, same as a fresh registration. */
|
||||
export async function acceptInvitationAction(token: string, formData: FormData): Promise<AcceptInvitationResult> {
|
||||
const parsed = acceptInvitationSchema.safeParse({
|
||||
password: formData.get("password"),
|
||||
confirmPassword: formData.get("confirmPassword"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: "Please fix the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors };
|
||||
}
|
||||
|
||||
const check = await validateInvitationToken(prisma, token);
|
||||
if (!check.valid) {
|
||||
const message =
|
||||
check.reason === "EXPIRED"
|
||||
? "This invitation link has expired. Ask the business owner to send you a new one."
|
||||
: check.reason === "ALREADY_ACCEPTED"
|
||||
? "This invitation has already been used — try signing in instead."
|
||||
: "This invitation link isn't valid.";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
|
||||
const result = await acceptInvitation(prisma, { token, password: parsed.data.password });
|
||||
if (!result.success) {
|
||||
return { success: false, error: "Couldn't accept this invitation. Please try again." };
|
||||
}
|
||||
|
||||
await signIn("credentials", {
|
||||
email: check.invitation.staff.user.email,
|
||||
password: parsed.data.password,
|
||||
redirectTo: "/staff",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { acceptInvitationAction } from "@/features/auth/actions/accept-invitation";
|
||||
import { acceptInvitationSchema } from "@/features/auth/schemas";
|
||||
import type { AcceptInvitationInput } from "@/features/auth/schemas";
|
||||
|
||||
export function AcceptInvitationForm({ token, businessName }: { token: string; businessName: string }) {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<AcceptInvitationInput>({ resolver: zodResolver(acceptInvitationSchema) });
|
||||
|
||||
const onSubmit = async (data: AcceptInvitationInput) => {
|
||||
setFormError(null);
|
||||
const formData = new FormData();
|
||||
formData.set("password", data.password);
|
||||
formData.set("confirmPassword", data.confirmPassword);
|
||||
|
||||
const result = await acceptInvitationAction(token, formData);
|
||||
if (!result.success) {
|
||||
setFormError(result.error ?? "Something went wrong. Please try again.");
|
||||
if (result.fieldErrors) {
|
||||
for (const [field, messages] of Object.entries(result.fieldErrors)) {
|
||||
if (messages?.[0]) setError(field as keyof AcceptInvitationInput, { message: messages[0] });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<p className="text-muted text-sm">
|
||||
You've been invited to join <span className="text-ink font-bold">{businessName}</span> on NextChair. Set
|
||||
a password to finish setting up your account.
|
||||
</p>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
error={errors.password?.message}
|
||||
{...register("password")}
|
||||
/>
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
{formError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" className="mt-4 w-full" isLoading={isSubmitting}>
|
||||
Set Password & Sign In
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -26,3 +26,14 @@ export const registerSchema = z
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||
|
||||
export const acceptInvitationSchema = z
|
||||
.object({
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
export type AcceptInvitationInput = z.infer<typeof acceptInvitationSchema>;
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "../authorization";
|
||||
import { deactivateStaff, updateStaffAsOwner } from "@/services/staff/staff.service";
|
||||
import { deleteCategory } from "@/services/catalogue/service-catalogue.service";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
|
||||
/**
|
||||
* Real-DB coverage of "a normal barber must not be able to perform owner
|
||||
* actions" and "owner actions never leak across businesses" — the two
|
||||
* defense-in-depth rules the Phase 6 brief calls out repeatedly. This
|
||||
* re-derives permissions from the database exactly as the owner Server
|
||||
* Actions do (assertOwnerPermission -> hasPermission -> a fresh
|
||||
* StaffProfile/Role/RolePermission query), rather than trusting a cached
|
||||
* session claim.
|
||||
*/
|
||||
describe("Owner authorization and business isolation (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
describe("hasPermission / assertOwnerPermission", () => {
|
||||
it("grants MANAGE_STAFF to a staff member whose role holds it", async () => {
|
||||
const ownerRole = await prisma.role.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
name: "Owner",
|
||||
permissions: { create: [{ permission: "MANAGE_STAFF" }] },
|
||||
},
|
||||
});
|
||||
const { userId } = await createTestStaff(business);
|
||||
await prisma.staffProfile.update({ where: { userId }, data: { roleId: ownerRole.id } });
|
||||
|
||||
expect(await hasPermission(userId, "MANAGE_STAFF")).toBe(true);
|
||||
await expect(assertOwnerPermission(userId, "MANAGE_STAFF")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("denies MANAGE_STAFF to an ordinary barber whose role doesn't hold it", async () => {
|
||||
const { userId } = await createTestStaff(business); // createTestStaff's auto-role has no permissions
|
||||
|
||||
expect(await hasPermission(userId, "MANAGE_STAFF")).toBe(false);
|
||||
await expect(assertOwnerPermission(userId, "MANAGE_STAFF")).rejects.toBeInstanceOf(OwnerPermissionDeniedError);
|
||||
});
|
||||
|
||||
it("reflects a permission change immediately — no caching across the role edit", async () => {
|
||||
const barberRole = await prisma.role.create({ data: { businessId: business.businessId, name: "Barber" } });
|
||||
const { userId } = await createTestStaff(business);
|
||||
await prisma.staffProfile.update({ where: { userId }, data: { roleId: barberRole.id } });
|
||||
expect(await hasPermission(userId, "MANAGE_SERVICES")).toBe(false);
|
||||
|
||||
await prisma.rolePermission.create({ data: { roleId: barberRole.id, permission: "MANAGE_SERVICES" } });
|
||||
expect(await hasPermission(userId, "MANAGE_SERVICES")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("business isolation", () => {
|
||||
it("updateStaffAsOwner can't reach a staff member from a different business", async () => {
|
||||
const otherBusiness = await createTestBusiness();
|
||||
try {
|
||||
const { staffId: otherStaffId } = await createTestStaff(otherBusiness);
|
||||
const result = await updateStaffAsOwner(prisma, {
|
||||
staffId: otherStaffId,
|
||||
businessId: business.businessId,
|
||||
input: { bio: "hijacked" },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) throw new Error("unreachable");
|
||||
expect(result.validation.issues[0]?.code).toBe("STAFF_NOT_FOUND");
|
||||
|
||||
const unaffected = await prisma.staffProfile.findUniqueOrThrow({ where: { id: otherStaffId } });
|
||||
expect(unaffected.bio).not.toBe("hijacked");
|
||||
} finally {
|
||||
await cleanupTestBusiness(otherBusiness);
|
||||
}
|
||||
});
|
||||
|
||||
it("deactivateStaff can't reach a staff member from a different business", async () => {
|
||||
const otherBusiness = await createTestBusiness();
|
||||
try {
|
||||
const { staffId: otherStaffId } = await createTestStaff(otherBusiness);
|
||||
const result = await deactivateStaff(prisma, { staffId: otherStaffId, businessId: business.businessId });
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
const unaffected = await prisma.staffProfile.findUniqueOrThrow({ where: { id: otherStaffId } });
|
||||
expect(unaffected.status).toBe("ACTIVE");
|
||||
} finally {
|
||||
await cleanupTestBusiness(otherBusiness);
|
||||
}
|
||||
});
|
||||
|
||||
it("deleteCategory can't reach a category from a different business", async () => {
|
||||
const otherBusiness = await createTestBusiness();
|
||||
try {
|
||||
// Move the other business's fixture category to be empty so only
|
||||
// business-scoping (not the has-services guard) is under test.
|
||||
await prisma.service.deleteMany({ where: { businessId: otherBusiness.businessId } });
|
||||
const result = await deleteCategory(prisma, {
|
||||
categoryId: otherBusiness.categoryId,
|
||||
businessId: business.businessId,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) throw new Error("unreachable");
|
||||
expect(result.validation.issues[0]?.code).toBe("CATEGORY_NOT_FOUND");
|
||||
|
||||
const stillExists = await prisma.serviceCategory.findUnique({ where: { id: otherBusiness.categoryId } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
} finally {
|
||||
await cleanupTestBusiness(otherBusiness);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { AppointmentActionSet } from "@/features/staff/components/appointment-status-actions";
|
||||
import {
|
||||
ownerCancelAppointmentAction,
|
||||
ownerCheckInAppointmentAction,
|
||||
ownerCompleteAppointmentAction,
|
||||
ownerConfirmAppointmentAction,
|
||||
ownerMarkNoShowAction,
|
||||
ownerStartAppointmentAction,
|
||||
} from "./appointments";
|
||||
|
||||
/**
|
||||
* A plain object assembling the owner Server Actions into the shape
|
||||
* AppointmentStatusActions expects — kept in its own (non-"use server")
|
||||
* module because a "use server" file may only export async functions, not
|
||||
* an object literal, even one whose values are all Server Actions.
|
||||
*/
|
||||
export const ownerAppointmentActions: AppointmentActionSet = {
|
||||
confirm: ownerConfirmAppointmentAction,
|
||||
checkIn: ownerCheckInAppointmentAction,
|
||||
start: ownerStartAppointmentAction,
|
||||
complete: ownerCompleteAppointmentAction,
|
||||
markNoShow: ownerMarkNoShowAction,
|
||||
cancel: ownerCancelAppointmentAction,
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
||||
import { cancelBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
|
||||
import {
|
||||
checkIn,
|
||||
complete,
|
||||
confirm,
|
||||
getAppointmentById,
|
||||
markNoShow,
|
||||
start,
|
||||
} from "@/services/scheduling/appointment.service";
|
||||
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import type { StaffAppointmentActionResult } from "@/features/staff/actions/appointment-actions";
|
||||
|
||||
/**
|
||||
* Owner equivalents of src/features/staff/actions/appointment-actions.ts —
|
||||
* same AppointmentService/BookingService/LoyaltyService calls, but scoped
|
||||
* to "any appointment in the owner's own business" (assertAppointmentInBusiness)
|
||||
* behind a MANAGE_ALL_APPOINTMENTS permission check, instead of "my own
|
||||
* appointments only". Barber permissions are untouched; this is a
|
||||
* parallel, explicit path, not a weakening of assertOwnAppointment — see
|
||||
* docs/phase-6-summary.md, "Permission and business-isolation rules".
|
||||
*/
|
||||
|
||||
async function loadOwnerAppointment(appointmentId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return { owner: null, appointment: null, error: "You must be signed in." } as const;
|
||||
}
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_ALL_APPOINTMENTS);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) {
|
||||
return { owner: null, appointment: null, error: error.message } as const;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) return { owner: null, appointment: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
const appointment = await getAppointmentById(prisma, appointmentId);
|
||||
if (!appointment) return { owner, appointment: null, error: "Appointment not found." } as const;
|
||||
|
||||
try {
|
||||
assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.businessId });
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError) {
|
||||
return { owner, appointment: null, error: error.message } as const;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { owner, appointment, error: null } as const;
|
||||
}
|
||||
|
||||
function friendlyTransitionMessage(code?: string): string {
|
||||
if (code === "INVALID_STATUS_TRANSITION") {
|
||||
return "This appointment can't move to that status right now — someone may have already updated it.";
|
||||
}
|
||||
if (code === "APPOINTMENT_NOT_FOUND") return "We couldn't find that appointment.";
|
||||
return "Something went wrong. Please try again.";
|
||||
}
|
||||
|
||||
export async function ownerConfirmAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnerAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await confirm(prisma, appointmentId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
export async function ownerCheckInAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnerAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await checkIn(prisma, appointmentId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
export async function ownerStartAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnerAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await start(prisma, appointmentId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
export async function ownerCompleteAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
|
||||
const { owner, appointment, error } = await loadOwnerAppointment(appointmentId);
|
||||
if (!appointment || !owner) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await complete(prisma, appointmentId);
|
||||
if (!result.success) {
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
const loyaltyResult = await awardStampForCompletedAppointment(prisma, { appointmentId, staffId: appointment.staffId });
|
||||
return { success: true, loyaltyStampAwarded: loyaltyResult.success && !loyaltyResult.alreadyAwarded };
|
||||
}
|
||||
|
||||
export async function ownerMarkNoShowAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnerAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await markNoShow(prisma, appointmentId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
export async function ownerCancelAppointmentAction(params: {
|
||||
appointmentId: string;
|
||||
reason?: string;
|
||||
}): Promise<StaffAppointmentActionResult> {
|
||||
const { owner, appointment, error } = await loadOwnerAppointment(params.appointmentId);
|
||||
if (!appointment || !owner) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await cancelBooking(prisma, {
|
||||
appointmentId: params.appointmentId,
|
||||
cancelledByUserId: owner.userId,
|
||||
reason: params.reason,
|
||||
});
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment." };
|
||||
}
|
||||
|
||||
export interface OwnerRescheduleResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Reassignment is just a reschedule with a different staffId and the same slot — see docs/phase-6-summary.md, "Appointment reassignment approach". Also covers a genuine date/time reschedule when startAt changes. */
|
||||
export async function ownerRescheduleAppointmentAction(params: {
|
||||
appointmentId: string;
|
||||
staffId?: string;
|
||||
startAt: string;
|
||||
}): Promise<OwnerRescheduleResult> {
|
||||
const { owner, appointment, error } = await loadOwnerAppointment(params.appointmentId);
|
||||
if (!appointment || !owner) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const previousStaffId = appointment.staffId;
|
||||
|
||||
const result = await rescheduleBooking(prisma, {
|
||||
appointmentId: params.appointmentId,
|
||||
staffId: params.staffId,
|
||||
startAt: new Date(params.startAt),
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this appointment." };
|
||||
}
|
||||
|
||||
if (params.staffId && params.staffId !== previousStaffId) {
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "APPOINTMENT_REASSIGNED",
|
||||
entityType: "Appointment",
|
||||
entityId: params.appointmentId,
|
||||
metadata: { fromStaffId: previousStaffId, toStaffId: params.staffId },
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { updateBookingRule } from "@/services/scheduling/booking-rule.service";
|
||||
import type { BookingRuleInput } from "@/services/scheduling/booking-rule.service";
|
||||
|
||||
export interface BookingRuleActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function updateBookingRuleAction(input: BookingRuleInput): Promise<BookingRuleActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { success: false, error: "You must be signed in." };
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_BOOKING_RULES);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { success: false, error: error.message };
|
||||
throw error;
|
||||
}
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await updateBookingRule(prisma, { businessId: owner.businessId, input });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save booking rules." };
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "BOOKING_RULES_UPDATED",
|
||||
entityType: "BookingRule",
|
||||
entityId: owner.businessId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import {
|
||||
setOpeningHours,
|
||||
updateBusinessProfile,
|
||||
updateBusinessSettings,
|
||||
} from "@/services/business/business.service";
|
||||
import type { BusinessLocationInput, BusinessProfileInput, BusinessSettingsInput, OpeningHoursDayInput } from "@/services/business/business.service";
|
||||
|
||||
export interface BusinessActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function requireBusinessManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { staff, error: null } as const;
|
||||
}
|
||||
|
||||
export async function updateBusinessProfileAction(params: {
|
||||
profile: BusinessProfileInput;
|
||||
location: BusinessLocationInput;
|
||||
}): Promise<BusinessActionResult> {
|
||||
const { staff, error } = await requireBusinessManager();
|
||||
if (!staff) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateBusinessProfile(prisma, { businessId: staff.businessId, ...params });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save business details." };
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: staff.userId,
|
||||
action: "BUSINESS_PROFILE_UPDATED",
|
||||
entityType: "Business",
|
||||
entityId: staff.businessId,
|
||||
metadata: { name: params.profile.name },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateBusinessSettingsAction(settings: BusinessSettingsInput): Promise<BusinessActionResult> {
|
||||
const { staff, error } = await requireBusinessManager();
|
||||
if (!staff) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
await updateBusinessSettings(prisma, { businessId: staff.businessId, settings });
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: staff.userId,
|
||||
action: "BUSINESS_SETTINGS_UPDATED",
|
||||
entityType: "BusinessSettings",
|
||||
entityId: staff.businessId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface OpeningHoursActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: { id: string; customerName: string; serviceNames: string; startAt: string; staffName: string }[];
|
||||
}
|
||||
|
||||
export async function updateOpeningHoursAction(
|
||||
days: OpeningHoursDayInput[],
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<OpeningHoursActionResult> {
|
||||
const { staff, error } = await requireBusinessManager();
|
||||
if (!staff) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const business = await prisma.business.findUnique({ where: { id: staff.businessId }, select: { timezone: true } });
|
||||
if (!business) return { success: false, error: "Business not found." };
|
||||
|
||||
const result = await setOpeningHours(prisma, {
|
||||
businessId: staff.businessId,
|
||||
businessTimezone: business.timezone,
|
||||
days,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't save opening hours.",
|
||||
conflicts: result.conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
staffName: c.staff.user.name ?? "Staff",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: staff.userId,
|
||||
action: "BUSINESS_HOURS_UPDATED",
|
||||
entityType: "BusinessHours",
|
||||
entityId: staff.businessId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { createTimeOff } from "@/services/staff/staff.service";
|
||||
|
||||
export interface OwnerBlockTimeConflict {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface OwnerBlockTimeResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: OwnerBlockTimeConflict[];
|
||||
}
|
||||
|
||||
/** Owner variant of Phase 5's blockTimeAction — same underlying StaffService.createTimeOff, just targeting any staff member in the business rather than only the caller's own schedule. See docs/phase-5-summary.md for why Block Time is a partial-day TimeOff row, not a separate model. */
|
||||
export async function ownerBlockTimeAction(input: {
|
||||
staffId: string;
|
||||
date: string;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
reason: string;
|
||||
acknowledgeConflicts?: boolean;
|
||||
}): Promise<OwnerBlockTimeResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { success: false, error: "You must be signed in." };
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { success: false, error: error.message };
|
||||
throw error;
|
||||
}
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { timezone: true } } },
|
||||
});
|
||||
if (!owner) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const target = await prisma.staffProfile.findFirst({
|
||||
where: { id: input.staffId, businessId: owner.businessId },
|
||||
});
|
||||
if (!target) return { success: false, error: "Staff member not found." };
|
||||
|
||||
const result = await createTimeOff(prisma, {
|
||||
staffId: input.staffId,
|
||||
businessTimezone: owner.business.timezone,
|
||||
input: {
|
||||
startDate: input.date,
|
||||
endDate: input.date,
|
||||
reason: input.reason.trim() || "Blocked",
|
||||
startMinutes: input.startMinutes,
|
||||
endMinutes: input.endMinutes,
|
||||
},
|
||||
acknowledgeConflicts: input.acknowledgeConflicts,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Something went wrong. Please try again.",
|
||||
conflicts: result.conflicts?.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import {
|
||||
createCategory,
|
||||
createService,
|
||||
deleteCategory,
|
||||
reorderCategories,
|
||||
reorderServices,
|
||||
setCategoryActive,
|
||||
setServiceActive,
|
||||
updateCategory,
|
||||
updateService,
|
||||
} from "@/services/catalogue/service-catalogue.service";
|
||||
import type { CategoryInput, ServiceInput } from "@/services/catalogue/service-catalogue.service";
|
||||
|
||||
export interface CatalogueActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function requireServiceManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_SERVICES);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { staff, error: null } as const;
|
||||
}
|
||||
|
||||
export async function createCategoryAction(input: CategoryInput): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await createCategory(prisma, { businessId: owner.businessId, input });
|
||||
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create category." };
|
||||
|
||||
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "CATEGORY_CREATED", entityType: "ServiceCategory", entityId: result.category.id });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateCategoryAction(categoryId: string, input: CategoryInput): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateCategory(prisma, { categoryId, businessId: owner.businessId, input });
|
||||
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update category." };
|
||||
|
||||
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "CATEGORY_UPDATED", entityType: "ServiceCategory", entityId: categoryId });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function setCategoryActiveAction(categoryId: string, isActive: boolean): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await setCategoryActive(prisma, { categoryId, businessId: owner.businessId, isActive });
|
||||
if (!result.success) return { success: false, error: "Category not found." };
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: isActive ? "CATEGORY_ACTIVATED" : "CATEGORY_DEACTIVATED",
|
||||
entityType: "ServiceCategory",
|
||||
entityId: categoryId,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteCategoryAction(categoryId: string): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await deleteCategory(prisma, { categoryId, businessId: owner.businessId });
|
||||
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't delete category." };
|
||||
|
||||
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "CATEGORY_DELETED", entityType: "ServiceCategory", entityId: categoryId });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function reorderCategoriesAction(orderedCategoryIds: string[]): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
await reorderCategories(prisma, { businessId: owner.businessId, orderedCategoryIds });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function createServiceAction(input: ServiceInput): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await createService(prisma, { businessId: owner.businessId, input });
|
||||
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create service." };
|
||||
|
||||
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "SERVICE_CREATED", entityType: "Service", entityId: result.service.id, metadata: { name: input.name } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateServiceAction(serviceId: string, input: ServiceInput): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateService(prisma, { serviceId, businessId: owner.businessId, input });
|
||||
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update service." };
|
||||
|
||||
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "SERVICE_UPDATED", entityType: "Service", entityId: serviceId });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function setServiceActiveAction(serviceId: string, isActive: boolean): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await setServiceActive(prisma, { serviceId, businessId: owner.businessId, isActive });
|
||||
if (!result.success) return { success: false, error: "Service not found." };
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: isActive ? "SERVICE_ACTIVATED" : "SERVICE_DEACTIVATED",
|
||||
entityType: "Service",
|
||||
entityId: serviceId,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function reorderServicesAction(categoryId: string, orderedServiceIds: string[]): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
await reorderServices(prisma, { businessId: owner.businessId, categoryId, orderedServiceIds });
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { setEligibleServices, setEligibleStaff, updateProgrammeConfig } from "@/services/loyalty/loyalty.service";
|
||||
import type { ProgrammeConfigInput } from "@/services/loyalty/loyalty.service";
|
||||
|
||||
export interface LoyaltyConfigActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function requireLoyaltyManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { staff, error: null } as const;
|
||||
}
|
||||
|
||||
export async function updateLoyaltyConfigAction(params: {
|
||||
config: ProgrammeConfigInput;
|
||||
eligibleServiceIds: string[];
|
||||
eligibleStaffIds: string[];
|
||||
}): Promise<LoyaltyConfigActionResult> {
|
||||
const { staff: owner, error } = await requireLoyaltyManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateProgrammeConfig(prisma, { businessId: owner.businessId, input: params.config });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save loyalty settings." };
|
||||
}
|
||||
|
||||
const programme = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: owner.businessId } });
|
||||
await Promise.all([
|
||||
setEligibleServices(prisma, { loyaltyProgrammeId: programme.id, serviceIds: params.eligibleServiceIds }),
|
||||
setEligibleStaff(prisma, { loyaltyProgrammeId: programme.id, staffIds: params.eligibleStaffIds }),
|
||||
]);
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "LOYALTY_CONFIG_UPDATED",
|
||||
entityType: "LoyaltyProgramme",
|
||||
entityId: programme.id,
|
||||
metadata: { isEnabled: params.config.isEnabled, rewardType: params.config.rewardType },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { redeemReward } from "@/services/loyalty/loyalty.service";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
|
||||
export interface RedeemRewardResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
discountAmount?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redemption is an operational action a permitted barber does day to day
|
||||
* (not "configure the loyalty programme"), so it's gated on
|
||||
* MANAGE_LOYALTY_STAMPS — the same permission Phase 5 already grants to
|
||||
* the Barber role for adding/correcting stamps — rather than
|
||||
* MANAGE_LOYALTY_PROGRAMME, which is owner-only configuration. Both Owner
|
||||
* and Barber roles hold MANAGE_LOYALTY_STAMPS in the seed. See
|
||||
* docs/phase-6-summary.md, "Loyalty configuration and redemption approach".
|
||||
*/
|
||||
export async function redeemRewardAction(params: {
|
||||
rewardId: string;
|
||||
appointmentId: string;
|
||||
}): Promise<RedeemRewardResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { success: false, error: "You must be signed in." };
|
||||
|
||||
const allowed = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_STAMPS);
|
||||
if (!allowed) return { success: false, error: "You don't have permission to do that." };
|
||||
|
||||
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await redeemReward(prisma, {
|
||||
rewardId: params.rewardId,
|
||||
appointmentId: params.appointmentId,
|
||||
staffId: staff.id,
|
||||
businessId: staff.businessId,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't redeem this reward." };
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: "LOYALTY_REWARD_REDEEMED",
|
||||
entityType: "LoyaltyReward",
|
||||
entityId: params.rewardId,
|
||||
metadata: { appointmentId: params.appointmentId, discountAmount: result.discountAmount },
|
||||
});
|
||||
|
||||
return { success: true, discountAmount: result.discountAmount };
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import {
|
||||
createStaffProfile,
|
||||
deactivateStaff,
|
||||
reactivateStaff,
|
||||
setStaffServiceEligibility,
|
||||
setWorkingHours,
|
||||
updateStaffAsOwner,
|
||||
} from "@/services/staff/staff.service";
|
||||
import type { CreateStaffInput, UpdateStaffAsOwnerInput, WorkingHoursDayInput } from "@/services/staff/staff.service";
|
||||
import { createInvitation, regenerateInvitation } from "@/services/staff/staff-invitation.service";
|
||||
|
||||
async function requireStaffManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { staff, error: null } as const;
|
||||
}
|
||||
|
||||
export interface AddStaffResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
staffId?: string;
|
||||
inviteToken?: string;
|
||||
}
|
||||
|
||||
export async function addStaffAction(input: CreateStaffInput): Promise<AddStaffResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await createStaffProfile(prisma, { businessId: owner.businessId, input });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add this staff member." };
|
||||
}
|
||||
|
||||
const invitation = await createInvitation(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffId: result.staff.id,
|
||||
email: input.email.toLowerCase(),
|
||||
invitedByUserId: owner.userId,
|
||||
});
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_ADDED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: result.staff.id,
|
||||
metadata: { name: input.name, email: input.email, roleId: input.roleId },
|
||||
});
|
||||
|
||||
return { success: true, staffId: result.staff.id, inviteToken: invitation.token };
|
||||
}
|
||||
|
||||
export interface StaffActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function updateStaffAction(staffId: string, input: UpdateStaffAsOwnerInput): Promise<StaffActionResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateStaffAsOwner(prisma, { staffId, businessId: owner.businessId, input });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this staff member." };
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: input.roleId ? "STAFF_ROLE_CHANGED" : "STAFF_UPDATED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
metadata: { ...(input.roleId ? { roleId: input.roleId } : {}) },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface DeactivateStaffResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: { id: string; customerName: string; serviceNames: string; startAt: string }[];
|
||||
}
|
||||
|
||||
export async function deactivateStaffAction(staffId: string, acknowledgeConflicts?: boolean): Promise<DeactivateStaffResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await deactivateStaff(prisma, { staffId, businessId: owner.businessId, acknowledgeConflicts });
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't deactivate this staff member.",
|
||||
conflicts: result.conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_DEACTIVATED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function reactivateStaffAction(staffId: string): Promise<StaffActionResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await reactivateStaff(prisma, { staffId, businessId: owner.businessId });
|
||||
if (!result.success) return { success: false, error: "Couldn't reactivate this staff member." };
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_REACTIVATED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface SetEligibilityResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: { id: string; customerName: string; serviceNames: string; startAt: string }[];
|
||||
}
|
||||
|
||||
export async function setStaffServiceEligibilityAction(
|
||||
staffId: string,
|
||||
serviceIds: string[],
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<SetEligibilityResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await setStaffServiceEligibility(prisma, { staffId, businessId: owner.businessId, serviceIds, acknowledgeConflicts });
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't update this staff member's services.",
|
||||
conflicts: result.conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_SERVICES_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
metadata: { serviceIds },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface WorkingHoursActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Owner-on-behalf-of-staff working hours — same StaffService.setWorkingHours call the barber's own Phase 5 editor uses, just targeting a chosen staffId instead of the caller. No conflict detection here, matching the existing self-service editor's behaviour (a documented gap — see docs/phase-6-summary.md). */
|
||||
export async function updateStaffWorkingHoursAction(
|
||||
staffId: string,
|
||||
days: WorkingHoursDayInput[],
|
||||
): Promise<WorkingHoursActionResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const target = await prisma.staffProfile.findFirst({ where: { id: staffId, businessId: owner.businessId } });
|
||||
if (!target) return { success: false, error: "Staff member not found." };
|
||||
|
||||
const result = await setWorkingHours(prisma, { staffId, days });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save working hours." };
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_WORKING_HOURS_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface RegenerateInvitationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
inviteToken?: string;
|
||||
}
|
||||
|
||||
export async function regenerateInvitationAction(staffId: string): Promise<RegenerateInvitationResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const invitation = await regenerateInvitation(prisma, { staffId, businessId: owner.businessId });
|
||||
if (!invitation) return { success: false, error: "No invitation found for this staff member." };
|
||||
|
||||
return { success: true, inviteToken: invitation.token };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Permission } from "@/generated/prisma/enums";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
|
||||
/**
|
||||
* Per-action owner permission checks — the mirror of
|
||||
* src/features/staff/authorization.ts and src/features/booking/authorization.ts.
|
||||
* The `(owner)` route group layout already gates on MANAGE_STAFF as a
|
||||
* coarse "is this person any kind of owner" shell check, but that is not
|
||||
* enough on its own (see docs/phase-6-summary.md, "Permission and
|
||||
* business-isolation rules") — every owner Server Action re-derives the
|
||||
* session and checks the *specific* permission that action needs, since a
|
||||
* future non-Owner role could plausibly hold some but not all of these.
|
||||
*/
|
||||
|
||||
export class OwnerPermissionDeniedError extends Error {
|
||||
constructor(message = "You don't have permission to do that.") {
|
||||
super(message);
|
||||
this.name = "OwnerPermissionDeniedError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertOwnerPermission(userId: string, permission: Permission): Promise<void> {
|
||||
const allowed = await hasPermission(userId, permission);
|
||||
if (!allowed) throw new OwnerPermissionDeniedError();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -13,11 +13,31 @@ import {
|
||||
markNoShowAction,
|
||||
startAppointmentAction,
|
||||
} from "../actions/appointment-actions";
|
||||
import type { StaffAppointmentActionResult } from "../actions/appointment-actions";
|
||||
|
||||
type RawStatus =
|
||||
"PENDING" | "CONFIRMED" | "CHECKED_IN" | "IN_PROGRESS" | "COMPLETED" | "CANCELLED" | "NO_SHOW";
|
||||
type ConfirmKind = "complete" | "no_show" | "cancel";
|
||||
|
||||
/** Same shape the owner variant (src/features/owner/actions/appointments.ts) implements — see AppointmentStatusActions' `actions` prop. */
|
||||
export interface AppointmentActionSet {
|
||||
confirm: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
|
||||
checkIn: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
|
||||
start: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
|
||||
complete: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
|
||||
markNoShow: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
|
||||
cancel: (params: { appointmentId: string; reason?: string }) => Promise<StaffAppointmentActionResult>;
|
||||
}
|
||||
|
||||
const DEFAULT_ACTIONS: AppointmentActionSet = {
|
||||
confirm: confirmAppointmentAction,
|
||||
checkIn: checkInAppointmentAction,
|
||||
start: startAppointmentAction,
|
||||
complete: completeAppointmentAction,
|
||||
markNoShow: markNoShowAction,
|
||||
cancel: cancelStaffAppointmentAction,
|
||||
};
|
||||
|
||||
/**
|
||||
* The status-appropriate action set — shared by Current Appointment and
|
||||
* Appointment Details, since both need exactly the same buttons for the
|
||||
@@ -25,14 +45,19 @@ type ConfirmKind = "complete" | "no_show" | "cancel";
|
||||
* AppointmentService's own state machine; this component never decides
|
||||
* whether a transition is legal, only which buttons to *offer* for a given
|
||||
* status (offering an illegal one would just get rejected server-side —
|
||||
* see appointment-actions.ts).
|
||||
* see appointment-actions.ts). `actions` defaults to the staff (own-only)
|
||||
* Server Actions; the owner appointment details page passes the owner
|
||||
* variant instead, so this one component serves both contexts without
|
||||
* forking — see docs/phase-6-summary.md, "Owner appointment access".
|
||||
*/
|
||||
export function AppointmentStatusActions({
|
||||
appointmentId,
|
||||
status,
|
||||
actions = DEFAULT_ACTIONS,
|
||||
}: {
|
||||
appointmentId: string;
|
||||
status: RawStatus;
|
||||
actions?: AppointmentActionSet;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@@ -59,10 +84,10 @@ export function AppointmentStatusActions({
|
||||
startTransition(async () => {
|
||||
const result =
|
||||
confirmKind === "complete"
|
||||
? await completeAppointmentAction(appointmentId)
|
||||
? await actions.complete(appointmentId)
|
||||
: confirmKind === "no_show"
|
||||
? await markNoShowAction(appointmentId)
|
||||
: await cancelStaffAppointmentAction({ appointmentId, reason: reason || undefined });
|
||||
? await actions.markNoShow(appointmentId)
|
||||
: await actions.cancel({ appointmentId, reason: reason || undefined });
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Something went wrong.");
|
||||
return;
|
||||
@@ -101,7 +126,7 @@ export function AppointmentStatusActions({
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={isPending}
|
||||
onClick={() => runQuiet(() => confirmAppointmentAction(appointmentId))}
|
||||
onClick={() => runQuiet(() => actions.confirm(appointmentId))}
|
||||
>
|
||||
Confirm Booking
|
||||
</Button>
|
||||
@@ -110,7 +135,7 @@ export function AppointmentStatusActions({
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={isPending}
|
||||
onClick={() => runQuiet(() => checkInAppointmentAction(appointmentId))}
|
||||
onClick={() => runQuiet(() => actions.checkIn(appointmentId))}
|
||||
>
|
||||
Check In
|
||||
</Button>
|
||||
@@ -119,7 +144,7 @@ export function AppointmentStatusActions({
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={isPending}
|
||||
onClick={() => runQuiet(() => startAppointmentAction(appointmentId))}
|
||||
onClick={() => runQuiet(() => actions.start(appointmentId))}
|
||||
>
|
||||
Start Appointment
|
||||
</Button>
|
||||
|
||||
@@ -15,15 +15,23 @@ export function DateStrip({
|
||||
businessTimezone,
|
||||
basePath,
|
||||
view,
|
||||
extraParams,
|
||||
}: {
|
||||
selectedDate: CalendarDate;
|
||||
businessTimezone: string;
|
||||
basePath: string;
|
||||
view?: string;
|
||||
/** Preserved across date navigation — e.g. the owner calendar's `?staffId=` filter. */
|
||||
extraParams?: Record<string, string>;
|
||||
}) {
|
||||
const today = todayIn(businessTimezone);
|
||||
const viewParam = view ? `&view=${view}` : "";
|
||||
const hrefFor = (date: CalendarDate) => `${basePath}?date=${date}${viewParam}`;
|
||||
const extraParamsString = extraParams
|
||||
? Object.entries(extraParams)
|
||||
.map(([key, value]) => `&${key}=${encodeURIComponent(value)}`)
|
||||
.join("")
|
||||
: "";
|
||||
const hrefFor = (date: CalendarDate) => `${basePath}?date=${date}${viewParam}${extraParamsString}`;
|
||||
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(selectedDate, i - 3));
|
||||
const label = new Date(`${selectedDate}T00:00:00`).toLocaleDateString("en-GB", {
|
||||
|
||||
@@ -52,6 +52,7 @@ export function DayCalendar({
|
||||
blocks,
|
||||
workingWindow,
|
||||
currentTimeMinutes,
|
||||
appointmentHrefBase = "/staff/appointments",
|
||||
}: {
|
||||
appointments: CalendarAppointment[];
|
||||
blocks: CalendarBlock[];
|
||||
@@ -59,6 +60,8 @@ export function DayCalendar({
|
||||
workingWindow: { startMinutes: number; endMinutes: number } | null;
|
||||
/** Minutes-from-midnight right now, only passed when the viewed day is today. */
|
||||
currentTimeMinutes: number | null;
|
||||
/** Owner's all-staff calendar reuses this component unchanged but links into /owner/appointments/[id] instead — see docs/phase-6-summary.md, "All-staff calendar approach". */
|
||||
appointmentHrefBase?: string;
|
||||
}) {
|
||||
const hourMarks = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i);
|
||||
const gridHeight = (END_HOUR - START_HOUR) * PX_PER_HOUR;
|
||||
@@ -121,7 +124,7 @@ export function DayCalendar({
|
||||
{appointments.map((appt) => (
|
||||
<Link
|
||||
key={appt.id}
|
||||
href={`/staff/appointments/${appt.id}`}
|
||||
href={`${appointmentHrefBase}/${appt.id}`}
|
||||
className={cn(
|
||||
"absolute inset-x-1 z-20 overflow-hidden rounded-lg border-l-4 bg-white p-2 shadow-sm transition-shadow hover:shadow-md",
|
||||
"focus-visible:ring-ink focus-visible:ring-2 focus-visible:ring-offset-1",
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { saveWorkingHoursAction } from "../actions/working-hours";
|
||||
import type { WorkingHoursActionResult } from "../actions/working-hours";
|
||||
import type { WorkingHoursDayInput } from "@/services/staff/staff.service";
|
||||
|
||||
const DAY_ORDER: Weekday[] = [
|
||||
Weekday.MONDAY,
|
||||
@@ -52,7 +54,16 @@ export interface InitialWorkingHoursRow {
|
||||
endMinutes: number | null;
|
||||
}
|
||||
|
||||
export function WorkingHoursForm({ initialRows, businessHoursLabel }: { initialRows: InitialWorkingHoursRow[]; businessHoursLabel: Record<Weekday, string> }) {
|
||||
export function WorkingHoursForm({
|
||||
initialRows,
|
||||
businessHoursLabel,
|
||||
onSave = saveWorkingHoursAction,
|
||||
}: {
|
||||
initialRows: InitialWorkingHoursRow[];
|
||||
businessHoursLabel: Record<Weekday, string>;
|
||||
/** Owner-on-behalf-of-staff editing (src/app/(owner)/owner/team/[id]/hours) injects its own Server Action here instead of the self-service default — same pattern as AppointmentStatusActions' `actions` prop. */
|
||||
onSave?: (days: WorkingHoursDayInput[]) => Promise<WorkingHoursActionResult>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [days, setDays] = useState<DayState[]>(() =>
|
||||
DAY_ORDER.map((dayOfWeek) => {
|
||||
@@ -84,7 +95,7 @@ export function WorkingHoursForm({ initialRows, businessHoursLabel }: { initialR
|
||||
const handleSave = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await saveWorkingHoursAction(
|
||||
const result = await onSave(
|
||||
days.map((d) => ({
|
||||
dayOfWeek: d.dayOfWeek,
|
||||
isOff: d.isOff,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* A thin, typed wrapper over the (previously unused, see schema.prisma)
|
||||
* AuditLog table — used for owner actions that change shared business
|
||||
* configuration (staff, roles, services, hours, booking rules, loyalty,
|
||||
* reward redemption). Never stores passwords, invitation tokens, or other
|
||||
* secrets in `metadata` — callers pass only display-safe before/after
|
||||
* values.
|
||||
*/
|
||||
export async function writeAuditLog(
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
actorUserId: string | null;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
metadata?: Prisma.InputJsonValue;
|
||||
},
|
||||
): Promise<void> {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
actorUserId: params.actorUserId,
|
||||
action: params.action,
|
||||
entityType: params.entityType,
|
||||
entityId: params.entityId,
|
||||
metadata: params.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { BottomNavItem } from "@/components/navigation/bottom-nav";
|
||||
|
||||
/**
|
||||
* Single source of truth for the staff/owner bottom nav. The templates had
|
||||
* two competing 4-item sets for what is conceptually the same nav
|
||||
* Single source of truth for the staff bottom nav. The templates had two
|
||||
* competing 4-item sets for what is conceptually the same nav
|
||||
* (Calendar/Current/Clients/Settings on most staff+owner screens vs.
|
||||
* Dashboard/Staff/Calendar/Admin on the Team Management screen — see
|
||||
* docs/implementation-plan.md §5/§8). Owner-only screens (team, business
|
||||
* settings, etc.) are reached from Settings rather than getting their own
|
||||
* top-level tab, so this one list serves both the (staff) and (owner)
|
||||
* route groups.
|
||||
* docs/implementation-plan.md §5/§8). Phase 5 resolved that by having
|
||||
* (owner) reuse this same list; Phase 6 gives (owner) its own
|
||||
* OWNER_NAV_ITEMS below instead, per that phase's own explicit nav
|
||||
* requirement (Dashboard/Calendar/Team/Services/Settings) — see
|
||||
* docs/phase-6-summary.md, "Owner navigation".
|
||||
*
|
||||
* Icons are string keys, not component references — this module is
|
||||
* imported by Server Component layouts, and a Lucide icon component isn't
|
||||
@@ -21,3 +22,12 @@ export const STAFF_NAV_ITEMS: BottomNavItem[] = [
|
||||
{ href: "/staff/clients", label: "Clients", icon: "users" },
|
||||
{ href: "/staff/settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
||||
/** The (owner) route group's own nav — distinct from STAFF_NAV_ITEMS per the Phase 6 brief. "Team" covers staff management (incl. individual staff detail); "Services" covers categories+services; business/booking-rules/loyalty/notifications live under Settings. */
|
||||
export const OWNER_NAV_ITEMS: BottomNavItem[] = [
|
||||
{ href: "/owner", label: "Dashboard", icon: "chart-line" },
|
||||
{ href: "/owner/calendar", label: "Calendar", icon: "calendar-days" },
|
||||
{ href: "/owner/team", label: "Team", icon: "users" },
|
||||
{ href: "/owner/services", label: "Services", icon: "scissors" },
|
||||
{ href: "/owner/settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
@@ -0,0 +1,153 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import { getOpeningHours, setOpeningHours, updateBusinessProfile } from "../business.service";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
|
||||
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
|
||||
let date = addDays(todayIn(timezone), minDaysAhead);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (weekdayOf(date) === target) return date;
|
||||
date = addDays(date, 1);
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
describe("BusinessService (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
describe("updateBusinessProfile", () => {
|
||||
it("writes through to the Business and its primary BusinessLocation", async () => {
|
||||
const result = await updateBusinessProfile(prisma, {
|
||||
businessId: business.businessId,
|
||||
profile: {
|
||||
name: "Renamed Barbershop",
|
||||
timezone: business.timezone,
|
||||
currency: "GBP",
|
||||
locale: "en-GB",
|
||||
},
|
||||
location: { addressLine1: "1 New Street", city: "Testville", postalCode: "TE1 1ST", country: "GB" },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const updated = await prisma.business.findUniqueOrThrow({
|
||||
where: { id: business.businessId },
|
||||
include: { locations: true },
|
||||
});
|
||||
expect(updated.name).toBe("Renamed Barbershop");
|
||||
expect(updated.locations[0]?.city).toBe("Testville");
|
||||
});
|
||||
|
||||
it("rejects an empty business name without writing anything", async () => {
|
||||
const result = await updateBusinessProfile(prisma, {
|
||||
businessId: business.businessId,
|
||||
profile: { name: " ", timezone: business.timezone, currency: "GBP", locale: "en-GB" },
|
||||
location: { addressLine1: "1 New Street", city: "Testville", postalCode: "TE1 1ST", country: "GB" },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.validation.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
|
||||
const unchanged = await prisma.business.findUniqueOrThrow({ where: { id: business.businessId } });
|
||||
expect(unchanged.name).toBe("Test Barbershop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOpeningHours", () => {
|
||||
it("blocks closing a day that has a future confirmed appointment, and Save Anyway bypasses it", async () => {
|
||||
const { staffId } = await createTestStaff(business, { workingDays: [Weekday.MONDAY] });
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
|
||||
|
||||
const appointment = await prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId,
|
||||
status: "CONFIRMED",
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
endAt: wallClockToInstant(monday, 10 * 60 + 30, business.timezone),
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const days = [
|
||||
{ dayOfWeek: Weekday.MONDAY, isClosed: true },
|
||||
{ dayOfWeek: Weekday.TUESDAY, isClosed: false, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
|
||||
];
|
||||
|
||||
const blocked = await setOpeningHours(prisma, {
|
||||
businessId: business.businessId,
|
||||
businessTimezone: business.timezone,
|
||||
days,
|
||||
});
|
||||
expect(blocked.success).toBe(false);
|
||||
if (blocked.success) throw new Error("unreachable");
|
||||
expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id);
|
||||
|
||||
// The appointment is untouched — never auto-cancelled by tightening hours.
|
||||
const stillConfirmed = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
|
||||
expect(stillConfirmed.status).toBe("CONFIRMED");
|
||||
|
||||
const acknowledged = await setOpeningHours(prisma, {
|
||||
businessId: business.businessId,
|
||||
businessTimezone: business.timezone,
|
||||
days,
|
||||
acknowledgeConflicts: true,
|
||||
});
|
||||
expect(acknowledged.success).toBe(true);
|
||||
|
||||
const hours = await getOpeningHours(prisma, business.businessId);
|
||||
const mondayHours = hours.find((h) => h.dayOfWeek === Weekday.MONDAY);
|
||||
expect(mondayHours?.isClosed).toBe(true);
|
||||
|
||||
// The appointment still exists, unmodified, after the "Save Anyway".
|
||||
const afterSave = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
|
||||
expect(afterSave.status).toBe("CONFIRMED");
|
||||
});
|
||||
|
||||
it("does not flag a conflict for an appointment that falls inside the new hours", async () => {
|
||||
const { staffId } = await createTestStaff(business, { workingDays: [Weekday.MONDAY] });
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
|
||||
|
||||
await prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId,
|
||||
status: "CONFIRMED",
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
endAt: wallClockToInstant(monday, 10 * 60 + 30, business.timezone),
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await setOpeningHours(prisma, {
|
||||
businessId: business.businessId,
|
||||
businessTimezone: business.timezone,
|
||||
days: [{ dayOfWeek: Weekday.MONDAY, isClosed: false, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { validateBusinessProfileInput, validateOpeningHoursInput } from "../business.service";
|
||||
|
||||
describe("validateBusinessProfileInput", () => {
|
||||
const base = {
|
||||
name: "Blake Heritage Barbershop",
|
||||
timezone: "Europe/London",
|
||||
currency: "GBP",
|
||||
locale: "en-GB",
|
||||
};
|
||||
|
||||
it("accepts a complete profile", () => {
|
||||
expect(validateBusinessProfileInput(base).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an empty name", () => {
|
||||
const result = validateBusinessProfileInput({ ...base, name: " " });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
});
|
||||
|
||||
it("rejects a missing timezone/currency/locale", () => {
|
||||
const result = validateBusinessProfileInput({ ...base, timezone: "" });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateOpeningHoursInput", () => {
|
||||
it("accepts a day marked closed with no times", () => {
|
||||
const result = validateOpeningHoursInput([{ dayOfWeek: Weekday.SUNDAY, isClosed: true }]);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("requires open/close times for an open day", () => {
|
||||
const result = validateOpeningHoursInput([{ dayOfWeek: Weekday.MONDAY, isClosed: false }]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
});
|
||||
|
||||
it("rejects opening time at or after closing time", () => {
|
||||
const result = validateOpeningHoursInput([
|
||||
{ dayOfWeek: Weekday.TUESDAY, isClosed: false, opensAtMinutes: 600, closesAtMinutes: 600 },
|
||||
]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
|
||||
});
|
||||
|
||||
it("rejects times outside a single day", () => {
|
||||
const result = validateOpeningHoursInput([
|
||||
{ dayOfWeek: Weekday.WEDNESDAY, isClosed: false, opensAtMinutes: 0, closesAtMinutes: 25 * 60 },
|
||||
]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
|
||||
});
|
||||
|
||||
it("rejects a negative opening time", () => {
|
||||
const result = validateOpeningHoursInput([
|
||||
{ dayOfWeek: Weekday.THURSDAY, isClosed: false, opensAtMinutes: -30, closesAtMinutes: 600 },
|
||||
]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
|
||||
});
|
||||
|
||||
it("accepts a valid full week mixing open and closed days", () => {
|
||||
const result = validateOpeningHoursInput([
|
||||
{ dayOfWeek: Weekday.MONDAY, isClosed: false, opensAtMinutes: 10 * 60, closesAtMinutes: 19 * 60 },
|
||||
{ dayOfWeek: Weekday.SUNDAY, isClosed: true },
|
||||
]);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { calendarDateOf, minutesFromMidnight, weekdayOf } from "@/lib/time/business-time";
|
||||
import { fail, ok } from "@/lib/validation-result";
|
||||
import type { ValidationResult } from "@/lib/validation-result";
|
||||
|
||||
/**
|
||||
* The business's own profile, address, policies, and opening hours —
|
||||
* owner-only configuration that sits above everything StaffService and
|
||||
* BookingRule already cover. Kept separate from BookingRule (scheduling
|
||||
* policy) and LoyaltyService (loyalty) per the brief's own service
|
||||
* boundaries; this file only knows about Business/BusinessLocation/
|
||||
* BusinessSettings/BusinessHours.
|
||||
*/
|
||||
|
||||
export type BusinessServiceErrorCode =
|
||||
| "INVALID_INPUT"
|
||||
| "INVALID_TIME_RANGE"
|
||||
| "APPOINTMENT_CONFLICTS";
|
||||
|
||||
export interface BusinessProfileInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
logoUrl?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
instagramUrl?: string;
|
||||
facebookUrl?: string;
|
||||
timezone: string;
|
||||
currency: string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export interface BusinessLocationInput {
|
||||
addressLine1: string;
|
||||
addressLine2?: string;
|
||||
city: string;
|
||||
region?: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
export interface BusinessSettingsInput {
|
||||
cancellationPolicy?: string;
|
||||
lateArrivalPolicy?: string;
|
||||
bookingTerms?: string;
|
||||
privacyPolicyUrl?: string;
|
||||
}
|
||||
|
||||
export function validateBusinessProfileInput(
|
||||
input: BusinessProfileInput,
|
||||
): ValidationResult<BusinessServiceErrorCode> {
|
||||
if (!input.name.trim()) {
|
||||
return fail({ code: "INVALID_INPUT", message: "Business name is required." });
|
||||
}
|
||||
if (!input.timezone.trim() || !input.currency.trim() || !input.locale.trim()) {
|
||||
return fail({ code: "INVALID_INPUT", message: "Timezone, currency, and locale are required." });
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
export async function getBusinessProfile(prisma: PrismaClient, businessId: string) {
|
||||
return prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
include: {
|
||||
locations: { where: { isPrimary: true }, take: 1 },
|
||||
settings: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateBusinessProfile(
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
businessId: string;
|
||||
profile: BusinessProfileInput;
|
||||
location: BusinessLocationInput;
|
||||
},
|
||||
): Promise<{ success: boolean; validation: ValidationResult<BusinessServiceErrorCode> }> {
|
||||
const validation = validateBusinessProfileInput(params.profile);
|
||||
if (!validation.valid) return { success: false, validation };
|
||||
if (!params.location.addressLine1.trim() || !params.location.city.trim() || !params.location.postalCode.trim()) {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({ code: "INVALID_INPUT", message: "Address line 1, city, and postcode are required." }),
|
||||
};
|
||||
}
|
||||
|
||||
const existingLocation = await prisma.businessLocation.findFirst({
|
||||
where: { businessId: params.businessId, isPrimary: true },
|
||||
});
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.business.update({
|
||||
where: { id: params.businessId },
|
||||
data: { ...params.profile },
|
||||
}),
|
||||
existingLocation
|
||||
? prisma.businessLocation.update({
|
||||
where: { id: existingLocation.id },
|
||||
data: { ...params.location },
|
||||
})
|
||||
: prisma.businessLocation.create({
|
||||
data: { businessId: params.businessId, isPrimary: true, ...params.location },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { success: true, validation: ok() };
|
||||
}
|
||||
|
||||
export async function updateBusinessSettings(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; settings: BusinessSettingsInput },
|
||||
): Promise<{ success: boolean }> {
|
||||
await prisma.businessSettings.upsert({
|
||||
where: { businessId: params.businessId },
|
||||
create: { businessId: params.businessId, ...params.settings },
|
||||
update: { ...params.settings },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Opening hours ──────────────────────────────────────────────────────
|
||||
|
||||
export interface OpeningHoursDayInput {
|
||||
dayOfWeek: Weekday;
|
||||
isClosed: boolean;
|
||||
opensAtMinutes?: number;
|
||||
closesAtMinutes?: number;
|
||||
}
|
||||
|
||||
export function validateOpeningHoursInput(
|
||||
days: OpeningHoursDayInput[],
|
||||
): ValidationResult<BusinessServiceErrorCode> {
|
||||
for (const day of days) {
|
||||
if (day.isClosed) continue;
|
||||
if (day.opensAtMinutes === undefined || day.closesAtMinutes === undefined) {
|
||||
return fail({
|
||||
code: "INVALID_INPUT",
|
||||
message: `${day.dayOfWeek}: opening and closing time are required for an open day.`,
|
||||
});
|
||||
}
|
||||
if (day.opensAtMinutes < 0 || day.closesAtMinutes > 24 * 60) {
|
||||
return fail({
|
||||
code: "INVALID_TIME_RANGE",
|
||||
message: `${day.dayOfWeek}: times must fall within a single day.`,
|
||||
});
|
||||
}
|
||||
if (day.opensAtMinutes >= day.closesAtMinutes) {
|
||||
return fail({
|
||||
code: "INVALID_TIME_RANGE",
|
||||
message: `${day.dayOfWeek}: opening time must be before closing time.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
export async function getOpeningHours(prisma: PrismaClient, businessId: string) {
|
||||
return prisma.businessHours.findMany({ where: { businessId }, orderBy: { dayOfWeek: "asc" } });
|
||||
}
|
||||
|
||||
const APPOINTMENT_CONFLICT_INCLUDE = {
|
||||
services: { include: { service: true } },
|
||||
customer: true,
|
||||
staff: { include: { user: { select: { name: true } } } },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Future, non-cancelled appointments that would fall outside the proposed
|
||||
* hours — either on a day newly marked closed, or before/after the new
|
||||
* open/close window on a day that stays open. Mirrors the Phase 5
|
||||
* break/time-off conflict pattern: this never cancels anything itself, it
|
||||
* only reports what the caller should show before asking for
|
||||
* acknowledgement.
|
||||
*/
|
||||
export async function findOpeningHoursConflicts(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; businessTimezone: string; days: OpeningHoursDayInput[] },
|
||||
) {
|
||||
const appointments = await prisma.appointment.findMany({
|
||||
where: {
|
||||
businessId: params.businessId,
|
||||
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
|
||||
startAt: { gte: new Date() },
|
||||
},
|
||||
include: APPOINTMENT_CONFLICT_INCLUDE,
|
||||
orderBy: { startAt: "asc" },
|
||||
});
|
||||
|
||||
const byDay = new Map(params.days.map((d) => [d.dayOfWeek, d]));
|
||||
|
||||
return appointments.filter((a) => {
|
||||
const weekday = weekdayOf(calendarDateOf(a.startAt, params.businessTimezone));
|
||||
const proposed = byDay.get(weekday);
|
||||
if (!proposed) return false;
|
||||
if (proposed.isClosed) return true;
|
||||
|
||||
const aStart = minutesFromMidnight(a.startAt, params.businessTimezone);
|
||||
const aEnd = minutesFromMidnight(a.endAt, params.businessTimezone);
|
||||
return aStart < (proposed.opensAtMinutes ?? 0) || aEnd > (proposed.closesAtMinutes ?? 24 * 60);
|
||||
});
|
||||
}
|
||||
|
||||
export async function setOpeningHours(
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
businessId: string;
|
||||
businessTimezone: string;
|
||||
days: OpeningHoursDayInput[];
|
||||
acknowledgeConflicts?: boolean;
|
||||
},
|
||||
) {
|
||||
const validation = validateOpeningHoursInput(params.days);
|
||||
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
|
||||
|
||||
if (!params.acknowledgeConflicts) {
|
||||
const conflicts = await findOpeningHoursConflicts(prisma, {
|
||||
businessId: params.businessId,
|
||||
businessTimezone: params.businessTimezone,
|
||||
days: params.days,
|
||||
});
|
||||
if (conflicts.length > 0) {
|
||||
return {
|
||||
success: false as const,
|
||||
conflicts,
|
||||
validation: fail({
|
||||
code: "APPOINTMENT_CONFLICTS",
|
||||
message: "These hours would leave existing appointments outside the business's open hours.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
params.days.map((day) =>
|
||||
prisma.businessHours.upsert({
|
||||
where: { businessId_dayOfWeek: { businessId: params.businessId, dayOfWeek: day.dayOfWeek } },
|
||||
create: {
|
||||
businessId: params.businessId,
|
||||
dayOfWeek: day.dayOfWeek,
|
||||
isClosed: day.isClosed,
|
||||
opensAtMinutes: day.isClosed ? null : (day.opensAtMinutes ?? null),
|
||||
closesAtMinutes: day.isClosed ? null : (day.closesAtMinutes ?? null),
|
||||
},
|
||||
update: {
|
||||
isClosed: day.isClosed,
|
||||
opensAtMinutes: day.isClosed ? null : (day.opensAtMinutes ?? null),
|
||||
closesAtMinutes: day.isClosed ? null : (day.closesAtMinutes ?? null),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return { success: true as const, conflicts: [], validation: ok() };
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createCategory,
|
||||
deleteCategory,
|
||||
findServiceUsageConflicts,
|
||||
updateService,
|
||||
} from "../service-catalogue.service";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
|
||||
describe("ServiceCatalogueService (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
describe("categories", () => {
|
||||
it("blocks deleting a category that still has services", async () => {
|
||||
// business.categoryId already owns the fixture haircut/beard-trim services.
|
||||
const result = await deleteCategory(prisma, { categoryId: business.categoryId, businessId: business.businessId });
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) throw new Error("unreachable");
|
||||
expect(result.validation.issues[0]?.code).toBe("CATEGORY_HAS_SERVICES");
|
||||
|
||||
const stillExists = await prisma.serviceCategory.findUnique({ where: { id: business.categoryId } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
it("allows deleting an empty category", async () => {
|
||||
const created = await createCategory(prisma, { businessId: business.businessId, input: { name: "Empty" } });
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
|
||||
const result = await deleteCategory(prisma, { categoryId: created.category.id, businessId: business.businessId });
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const gone = await prisma.serviceCategory.findUnique({ where: { id: created.category.id } });
|
||||
expect(gone).toBeNull();
|
||||
});
|
||||
|
||||
it("scopes deletion to the given business — a category from a different business is not found", async () => {
|
||||
const otherBusiness = await createTestBusiness();
|
||||
try {
|
||||
const result = await deleteCategory(prisma, {
|
||||
categoryId: otherBusiness.categoryId,
|
||||
businessId: business.businessId,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) throw new Error("unreachable");
|
||||
expect(result.validation.issues[0]?.code).toBe("CATEGORY_NOT_FOUND");
|
||||
} finally {
|
||||
await cleanupTestBusiness(otherBusiness);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("services", () => {
|
||||
it("editing a service's price never rewrites a past appointment's priceAtBooking snapshot", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
const pastAppointment = await prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId,
|
||||
status: "COMPLETED",
|
||||
startAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
endAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000 + 30 * 60_000),
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
|
||||
},
|
||||
},
|
||||
include: { services: true },
|
||||
});
|
||||
|
||||
const updated = await updateService(prisma, {
|
||||
serviceId: business.haircutServiceId,
|
||||
businessId: business.businessId,
|
||||
input: {
|
||||
categoryId: business.categoryId,
|
||||
name: "Haircut",
|
||||
durationMinutes: 45,
|
||||
price: "35.00",
|
||||
bufferBeforeMinutes: 0,
|
||||
bufferAfterMinutes: 0,
|
||||
allowOnlineBooking: true,
|
||||
},
|
||||
});
|
||||
expect(updated.success).toBe(true);
|
||||
|
||||
const snapshot = await prisma.appointmentService.findUniqueOrThrow({
|
||||
where: { id: pastAppointment.services[0]!.id },
|
||||
});
|
||||
expect(Number(snapshot.priceAtBooking)).toBe(20);
|
||||
expect(snapshot.durationAtBookingMinutes).toBe(30);
|
||||
|
||||
const liveService = await prisma.service.findUniqueOrThrow({ where: { id: business.haircutServiceId } });
|
||||
expect(Number(liveService.price)).toBe(35);
|
||||
expect(liveService.durationMinutes).toBe(45);
|
||||
});
|
||||
|
||||
it("findServiceUsageConflicts finds only future, non-cancelled appointments booking that service", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
const future = await prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId,
|
||||
status: "CONFIRMED",
|
||||
startAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
endAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000 + 30 * 60_000),
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Cancelled — must not appear in conflicts.
|
||||
await prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId,
|
||||
status: "CANCELLED",
|
||||
startAt: new Date(Date.now() + 8 * 24 * 60 * 60 * 1000),
|
||||
endAt: new Date(Date.now() + 8 * 24 * 60 * 60 * 1000 + 30 * 60_000),
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const conflicts = await findServiceUsageConflicts(prisma, {
|
||||
serviceId: business.haircutServiceId,
|
||||
businessId: business.businessId,
|
||||
});
|
||||
expect(conflicts.map((c) => c.id)).toEqual([future.id]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateCategoryInput, validateServiceInput } from "../service-catalogue.service";
|
||||
|
||||
describe("validateCategoryInput", () => {
|
||||
it("accepts a valid category name", () => {
|
||||
expect(validateCategoryInput({ name: "Beard Services" }).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an empty/whitespace-only name", () => {
|
||||
const result = validateCategoryInput({ name: " " });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateServiceInput", () => {
|
||||
const base = {
|
||||
categoryId: "cat-1",
|
||||
name: "Haircut & Style",
|
||||
durationMinutes: 30,
|
||||
price: "22.50",
|
||||
bufferBeforeMinutes: 0,
|
||||
bufferAfterMinutes: 5,
|
||||
allowOnlineBooking: true,
|
||||
};
|
||||
|
||||
it("accepts a fully valid service", () => {
|
||||
expect(validateServiceInput(base).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an empty name", () => {
|
||||
const result = validateServiceInput({ ...base, name: "" });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
});
|
||||
|
||||
it("rejects a missing category", () => {
|
||||
const result = validateServiceInput({ ...base, categoryId: "" });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
});
|
||||
|
||||
it("rejects a zero or negative duration", () => {
|
||||
const result = validateServiceInput({ ...base, durationMinutes: 0 });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_DURATION");
|
||||
});
|
||||
|
||||
it("rejects a non-integer duration", () => {
|
||||
const result = validateServiceInput({ ...base, durationMinutes: 30.5 });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_DURATION");
|
||||
});
|
||||
|
||||
it.each(["free", "-5.00", "5.999", "5,00", ""])("rejects an invalid price %s", (price) => {
|
||||
const result = validateServiceInput({ ...base, price });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_PRICE");
|
||||
});
|
||||
|
||||
it.each(["0", "22", "22.5", "22.50"])("accepts a valid decimal price %s", (price) => {
|
||||
expect(validateServiceInput({ ...base, price }).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a negative buffer", () => {
|
||||
const result = validateServiceInput({ ...base, bufferBeforeMinutes: -1 });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { fail, ok } from "@/lib/validation-result";
|
||||
import type { ValidationResult } from "@/lib/validation-result";
|
||||
|
||||
/**
|
||||
* Service categories and services — the owner-facing catalogue. Staff
|
||||
* eligibility (which barber can perform which service) stays in
|
||||
* StaffService (see docs/phase-6-summary.md, "Domain services") since it's
|
||||
* staff-scoped data, not catalogue data; this file only owns
|
||||
* ServiceCategory/Service themselves.
|
||||
*
|
||||
* Money is handled as strings end to end — a price never passes through a
|
||||
* JS `number` on its way into Prisma's `Decimal` column, so there's no
|
||||
* floating-point rounding step to get wrong.
|
||||
*/
|
||||
|
||||
export type ServiceCatalogueErrorCode =
|
||||
| "INVALID_INPUT"
|
||||
| "INVALID_PRICE"
|
||||
| "INVALID_DURATION"
|
||||
| "CATEGORY_NOT_FOUND"
|
||||
| "CATEGORY_HAS_SERVICES"
|
||||
| "SERVICE_NOT_FOUND"
|
||||
| "APPOINTMENT_CONFLICTS";
|
||||
|
||||
const DECIMAL_PRICE = /^\d+(\.\d{1,2})?$/;
|
||||
|
||||
// ── Categories ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface CategoryInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function validateCategoryInput(input: CategoryInput): ValidationResult<ServiceCatalogueErrorCode> {
|
||||
if (!input.name.trim()) return fail({ code: "INVALID_INPUT", message: "Category name is required." });
|
||||
return ok();
|
||||
}
|
||||
|
||||
export async function listCategories(prisma: PrismaClient, businessId: string) {
|
||||
return prisma.serviceCategory.findMany({
|
||||
where: { businessId },
|
||||
orderBy: { displayOrder: "asc" },
|
||||
include: { services: { orderBy: { displayOrder: "asc" } } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createCategory(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; input: CategoryInput },
|
||||
) {
|
||||
const validation = validateCategoryInput(params.input);
|
||||
if (!validation.valid) return { success: false as const, validation };
|
||||
|
||||
const maxOrder = await prisma.serviceCategory.aggregate({
|
||||
where: { businessId: params.businessId },
|
||||
_max: { displayOrder: true },
|
||||
});
|
||||
|
||||
const category = await prisma.serviceCategory.create({
|
||||
data: {
|
||||
businessId: params.businessId,
|
||||
name: params.input.name.trim(),
|
||||
displayOrder: (maxOrder._max.displayOrder ?? -1) + 1,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true as const, category, validation: ok() };
|
||||
}
|
||||
|
||||
export async function updateCategory(
|
||||
prisma: PrismaClient,
|
||||
params: { categoryId: string; businessId: string; input: CategoryInput },
|
||||
) {
|
||||
const validation = validateCategoryInput(params.input);
|
||||
if (!validation.valid) return { success: false as const, validation };
|
||||
|
||||
const existing = await prisma.serviceCategory.findFirst({
|
||||
where: { id: params.categoryId, businessId: params.businessId },
|
||||
});
|
||||
if (!existing) {
|
||||
return {
|
||||
success: false as const,
|
||||
validation: fail({ code: "CATEGORY_NOT_FOUND", message: "Category not found." }),
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.serviceCategory.update({
|
||||
where: { id: params.categoryId },
|
||||
data: { name: params.input.name.trim() },
|
||||
});
|
||||
return { success: true as const, validation: ok() };
|
||||
}
|
||||
|
||||
export async function setCategoryActive(
|
||||
prisma: PrismaClient,
|
||||
params: { categoryId: string; businessId: string; isActive: boolean },
|
||||
): Promise<{ success: boolean }> {
|
||||
const result = await prisma.serviceCategory.updateMany({
|
||||
where: { id: params.categoryId, businessId: params.businessId },
|
||||
data: { isActive: params.isActive },
|
||||
});
|
||||
return { success: result.count > 0 };
|
||||
}
|
||||
|
||||
export async function reorderCategories(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; orderedCategoryIds: string[] },
|
||||
): Promise<{ success: boolean }> {
|
||||
await prisma.$transaction(
|
||||
params.orderedCategoryIds.map((id, index) =>
|
||||
prisma.serviceCategory.updateMany({
|
||||
where: { id, businessId: params.businessId },
|
||||
data: { displayOrder: index },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Categories with services (active or not) can't be deleted outright — deactivate instead, per the brief's "prefer deactivation over destructive deletion where historical data depends on it." */
|
||||
export async function deleteCategory(
|
||||
prisma: PrismaClient,
|
||||
params: { categoryId: string; businessId: string },
|
||||
) {
|
||||
const existing = await prisma.serviceCategory.findFirst({
|
||||
where: { id: params.categoryId, businessId: params.businessId },
|
||||
include: { _count: { select: { services: true } } },
|
||||
});
|
||||
if (!existing) {
|
||||
return {
|
||||
success: false as const,
|
||||
validation: fail({ code: "CATEGORY_NOT_FOUND", message: "Category not found." }),
|
||||
};
|
||||
}
|
||||
if (existing._count.services > 0) {
|
||||
return {
|
||||
success: false as const,
|
||||
validation: fail({
|
||||
code: "CATEGORY_HAS_SERVICES",
|
||||
message: "This category still has services — move or remove them first, or deactivate the category instead.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.serviceCategory.delete({ where: { id: params.categoryId } });
|
||||
return { success: true as const, validation: ok() };
|
||||
}
|
||||
|
||||
// ── Services ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ServiceInput {
|
||||
categoryId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
durationMinutes: number;
|
||||
/** Decimal string, e.g. "22.50" — never a JS number, see file header. */
|
||||
price: string;
|
||||
bufferBeforeMinutes: number;
|
||||
bufferAfterMinutes: number;
|
||||
allowOnlineBooking: boolean;
|
||||
}
|
||||
|
||||
export function validateServiceInput(input: ServiceInput): ValidationResult<ServiceCatalogueErrorCode> {
|
||||
if (!input.name.trim()) return fail({ code: "INVALID_INPUT", message: "Service name is required." });
|
||||
if (!input.categoryId) return fail({ code: "INVALID_INPUT", message: "Choose a category." });
|
||||
if (!Number.isInteger(input.durationMinutes) || input.durationMinutes <= 0) {
|
||||
return fail({ code: "INVALID_DURATION", message: "Duration must be a positive number of minutes." });
|
||||
}
|
||||
if (!DECIMAL_PRICE.test(input.price)) {
|
||||
return fail({ code: "INVALID_PRICE", message: "Enter a price like 22.50, with at most two decimal places." });
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(input.bufferBeforeMinutes) ||
|
||||
!Number.isInteger(input.bufferAfterMinutes) ||
|
||||
input.bufferBeforeMinutes < 0 ||
|
||||
input.bufferAfterMinutes < 0
|
||||
) {
|
||||
return fail({ code: "INVALID_INPUT", message: "Buffers must be zero or a positive number of minutes." });
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
export async function listServices(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; categoryId?: string; q?: string },
|
||||
) {
|
||||
return prisma.service.findMany({
|
||||
where: {
|
||||
businessId: params.businessId,
|
||||
...(params.categoryId ? { categoryId: params.categoryId } : {}),
|
||||
...(params.q ? { name: { contains: params.q, mode: "insensitive" } } : {}),
|
||||
},
|
||||
include: { category: true },
|
||||
orderBy: [{ categoryId: "asc" }, { displayOrder: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function createService(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; input: ServiceInput },
|
||||
) {
|
||||
const validation = validateServiceInput(params.input);
|
||||
if (!validation.valid) return { success: false as const, validation };
|
||||
|
||||
const category = await prisma.serviceCategory.findFirst({
|
||||
where: { id: params.input.categoryId, businessId: params.businessId },
|
||||
});
|
||||
if (!category) {
|
||||
return {
|
||||
success: false as const,
|
||||
validation: fail({ code: "CATEGORY_NOT_FOUND", message: "Category not found." }),
|
||||
};
|
||||
}
|
||||
|
||||
const maxOrder = await prisma.service.aggregate({
|
||||
where: { businessId: params.businessId, categoryId: params.input.categoryId },
|
||||
_max: { displayOrder: true },
|
||||
});
|
||||
|
||||
const service = await prisma.service.create({
|
||||
data: {
|
||||
businessId: params.businessId,
|
||||
categoryId: params.input.categoryId,
|
||||
name: params.input.name.trim(),
|
||||
description: params.input.description?.trim() || null,
|
||||
durationMinutes: params.input.durationMinutes,
|
||||
price: params.input.price,
|
||||
bufferBeforeMinutes: params.input.bufferBeforeMinutes,
|
||||
bufferAfterMinutes: params.input.bufferAfterMinutes,
|
||||
allowOnlineBooking: params.input.allowOnlineBooking,
|
||||
displayOrder: (maxOrder._max.displayOrder ?? -1) + 1,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true as const, service, validation: ok() };
|
||||
}
|
||||
|
||||
const APPOINTMENT_CONFLICT_INCLUDE = {
|
||||
customer: true,
|
||||
staff: { include: { user: { select: { name: true } } } },
|
||||
} as const;
|
||||
|
||||
/** Future, non-cancelled appointments that booked this exact service — informational for an edit/deactivation confirmation, never auto-resolved. Historical AppointmentService rows (priceAtBooking/durationAtBookingMinutes) are untouched regardless. */
|
||||
export async function findServiceUsageConflicts(
|
||||
prisma: PrismaClient,
|
||||
params: { serviceId: string; businessId: string },
|
||||
) {
|
||||
return prisma.appointment.findMany({
|
||||
where: {
|
||||
businessId: params.businessId,
|
||||
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
|
||||
startAt: { gte: new Date() },
|
||||
services: { some: { serviceId: params.serviceId } },
|
||||
},
|
||||
include: APPOINTMENT_CONFLICT_INCLUDE,
|
||||
orderBy: { startAt: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateService(
|
||||
prisma: PrismaClient,
|
||||
params: { serviceId: string; businessId: string; input: ServiceInput },
|
||||
) {
|
||||
const validation = validateServiceInput(params.input);
|
||||
if (!validation.valid) return { success: false as const, validation };
|
||||
|
||||
const existing = await prisma.service.findFirst({
|
||||
where: { id: params.serviceId, businessId: params.businessId },
|
||||
});
|
||||
if (!existing) {
|
||||
return {
|
||||
success: false as const,
|
||||
validation: fail({ code: "SERVICE_NOT_FOUND", message: "Service not found." }),
|
||||
};
|
||||
}
|
||||
|
||||
// Price/duration snapshots on past AppointmentService rows are never
|
||||
// rewritten — this only ever changes the live Service row future
|
||||
// bookings will read at booking time. See docs/phase-6-summary.md.
|
||||
await prisma.service.update({
|
||||
where: { id: params.serviceId },
|
||||
data: {
|
||||
categoryId: params.input.categoryId,
|
||||
name: params.input.name.trim(),
|
||||
description: params.input.description?.trim() || null,
|
||||
durationMinutes: params.input.durationMinutes,
|
||||
price: params.input.price,
|
||||
bufferBeforeMinutes: params.input.bufferBeforeMinutes,
|
||||
bufferAfterMinutes: params.input.bufferAfterMinutes,
|
||||
allowOnlineBooking: params.input.allowOnlineBooking,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true as const, validation: ok() };
|
||||
}
|
||||
|
||||
export async function setServiceActive(
|
||||
prisma: PrismaClient,
|
||||
params: { serviceId: string; businessId: string; isActive: boolean },
|
||||
): Promise<{ success: boolean }> {
|
||||
const result = await prisma.service.updateMany({
|
||||
where: { id: params.serviceId, businessId: params.businessId },
|
||||
data: { isActive: params.isActive },
|
||||
});
|
||||
return { success: result.count > 0 };
|
||||
}
|
||||
|
||||
export async function reorderServices(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; categoryId: string; orderedServiceIds: string[] },
|
||||
): Promise<{ success: boolean }> {
|
||||
await prisma.$transaction(
|
||||
params.orderedServiceIds.map((id, index) =>
|
||||
prisma.service.updateMany({
|
||||
where: { id, businessId: params.businessId, categoryId: params.categoryId },
|
||||
data: { displayOrder: index },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateProgrammeConfig } from "../loyalty.service";
|
||||
import type { ProgrammeConfigInput } from "../loyalty.service";
|
||||
|
||||
const base: ProgrammeConfigInput = {
|
||||
isEnabled: true,
|
||||
stampsRequired: 10,
|
||||
rewardType: "PERCENTAGE",
|
||||
rewardValue: "50",
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
};
|
||||
|
||||
describe("validateProgrammeConfig", () => {
|
||||
it("accepts a valid percentage-reward configuration", () => {
|
||||
expect(validateProgrammeConfig(base).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a stamps-required of zero", () => {
|
||||
const result = validateProgrammeConfig({ ...base, stampsRequired: 0 });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
});
|
||||
|
||||
it("allows an incomplete configuration while disabled", () => {
|
||||
const result = validateProgrammeConfig({ ...base, isEnabled: false, rewardValue: undefined });
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it.each([0, 101, -5])("rejects an out-of-range percentage when enabled (%s)", (pct) => {
|
||||
const result = validateProgrammeConfig({ ...base, rewardValue: String(pct) });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_PERCENTAGE");
|
||||
});
|
||||
|
||||
it("rejects a missing percentage value when enabled", () => {
|
||||
const result = validateProgrammeConfig({ ...base, rewardValue: undefined });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_PERCENTAGE");
|
||||
});
|
||||
|
||||
it("accepts a valid fixed-amount reward", () => {
|
||||
const result = validateProgrammeConfig({ ...base, rewardType: "FIXED_AMOUNT", rewardValue: "10.00" });
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a negative fixed-amount reward", () => {
|
||||
const result = validateProgrammeConfig({ ...base, rewardType: "FIXED_AMOUNT", rewardValue: "-1" });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_VALUE");
|
||||
});
|
||||
|
||||
it("accepts a free-service reward with a service chosen", () => {
|
||||
const result = validateProgrammeConfig({ ...base, rewardType: "FREE_SERVICE", freeServiceId: "svc-1" });
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a free-service reward with no service chosen", () => {
|
||||
const result = validateProgrammeConfig({ ...base, rewardType: "FREE_SERVICE", freeServiceId: undefined });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("FREE_SERVICE_REQUIRED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
redeemReward,
|
||||
setEligibleServices,
|
||||
updateProgrammeConfig,
|
||||
} from "../loyalty.service";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
|
||||
describe("LoyaltyService — owner configuration and redemption (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
describe("updateProgrammeConfig", () => {
|
||||
it("creates the LoyaltyProgramme row on first save and updates it on the next", async () => {
|
||||
const first = await updateProgrammeConfig(prisma, {
|
||||
businessId: business.businessId,
|
||||
input: {
|
||||
isEnabled: true,
|
||||
stampsRequired: 8,
|
||||
rewardType: "PERCENTAGE",
|
||||
rewardValue: "25",
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
},
|
||||
});
|
||||
expect(first.success).toBe(true);
|
||||
|
||||
const programme = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.businessId } });
|
||||
expect(programme.stampsRequired).toBe(8);
|
||||
expect(Number(programme.rewardValue)).toBe(25);
|
||||
|
||||
const second = await updateProgrammeConfig(prisma, {
|
||||
businessId: business.businessId,
|
||||
input: {
|
||||
isEnabled: true,
|
||||
stampsRequired: 12,
|
||||
rewardType: "PERCENTAGE",
|
||||
rewardValue: "40",
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
},
|
||||
});
|
||||
expect(second.success).toBe(true);
|
||||
|
||||
const updated = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.businessId } });
|
||||
expect(updated.stampsRequired).toBe(12);
|
||||
expect(Number(updated.rewardValue)).toBe(40);
|
||||
});
|
||||
|
||||
it("rejects enabling a FREE_SERVICE reward pointing at an inactive service", async () => {
|
||||
const inactiveService = await prisma.service.update({
|
||||
where: { id: business.beardTrimServiceId },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
const result = await updateProgrammeConfig(prisma, {
|
||||
businessId: business.businessId,
|
||||
input: {
|
||||
isEnabled: true,
|
||||
stampsRequired: 10,
|
||||
rewardType: "FREE_SERVICE",
|
||||
freeServiceId: inactiveService.id,
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.validation.issues[0]?.code).toBe("FREE_SERVICE_REQUIRED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setEligibleServices", () => {
|
||||
it("replaces the eligible-services list wholesale", async () => {
|
||||
const programme = await prisma.loyaltyProgramme.create({
|
||||
data: { businessId: business.businessId, stampsRequired: 10, rewardType: "PERCENTAGE", rewardValue: 50 },
|
||||
});
|
||||
|
||||
await setEligibleServices(prisma, { loyaltyProgrammeId: programme.id, serviceIds: [business.haircutServiceId] });
|
||||
let rows = await prisma.loyaltyEligibleService.findMany({ where: { loyaltyProgrammeId: programme.id } });
|
||||
expect(rows.map((r) => r.serviceId)).toEqual([business.haircutServiceId]);
|
||||
|
||||
await setEligibleServices(prisma, { loyaltyProgrammeId: programme.id, serviceIds: [business.beardTrimServiceId] });
|
||||
rows = await prisma.loyaltyEligibleService.findMany({ where: { loyaltyProgrammeId: programme.id } });
|
||||
expect(rows.map((r) => r.serviceId)).toEqual([business.beardTrimServiceId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("redeemReward", () => {
|
||||
async function setUpRedeemableReward() {
|
||||
const programme = await prisma.loyaltyProgramme.create({
|
||||
data: { businessId: business.businessId, stampsRequired: 10, rewardType: "PERCENTAGE", rewardValue: 50 },
|
||||
});
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const account = await prisma.loyaltyAccount.create({
|
||||
data: { customerId, loyaltyProgrammeId: programme.id, currentStamps: 10 },
|
||||
});
|
||||
const reward = await prisma.loyaltyReward.create({
|
||||
data: { loyaltyAccountId: account.id, rewardType: "PERCENTAGE", value: 50, label: "50% Off", status: "AVAILABLE" },
|
||||
});
|
||||
const appointment = await prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId,
|
||||
status: "CONFIRMED",
|
||||
startAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
endAt: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60_000),
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
return { staffId, account, reward, appointment };
|
||||
}
|
||||
|
||||
it("applies the discount, marks the reward REDEEMED, and deducts stamps — all atomically", async () => {
|
||||
const { staffId, account, reward, appointment } = await setUpRedeemableReward();
|
||||
|
||||
const result = await redeemReward(prisma, {
|
||||
rewardId: reward.id,
|
||||
appointmentId: appointment.id,
|
||||
staffId,
|
||||
businessId: business.businessId,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.discountAmount).toBe("10.00"); // 50% of a 20.00 haircut
|
||||
|
||||
const updatedReward = await prisma.loyaltyReward.findUniqueOrThrow({ where: { id: reward.id } });
|
||||
expect(updatedReward.status).toBe("REDEEMED");
|
||||
expect(updatedReward.redeemedByStaffId).toBe(staffId);
|
||||
expect(updatedReward.appliedToAppointmentId).toBe(appointment.id);
|
||||
|
||||
const updatedAccount = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: account.id } });
|
||||
expect(updatedAccount.currentStamps).toBe(0);
|
||||
|
||||
const updatedAppointment = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
|
||||
expect(Number(updatedAppointment.discountAmount)).toBe(10);
|
||||
});
|
||||
|
||||
it("prevents redeeming the same reward twice — the second attempt reports REWARD_NOT_AVAILABLE, not a double deduction", async () => {
|
||||
const { staffId, account, reward, appointment } = await setUpRedeemableReward();
|
||||
|
||||
const first = await redeemReward(prisma, {
|
||||
rewardId: reward.id,
|
||||
appointmentId: appointment.id,
|
||||
staffId,
|
||||
businessId: business.businessId,
|
||||
});
|
||||
expect(first.success).toBe(true);
|
||||
|
||||
// A second appointment to apply to, since the first is now discounted.
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
void customerId;
|
||||
const secondAppointment = await prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId: account.customerId,
|
||||
status: "CONFIRMED",
|
||||
startAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000),
|
||||
endAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000 + 30 * 60_000),
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const second = await redeemReward(prisma, {
|
||||
rewardId: reward.id,
|
||||
appointmentId: secondAppointment.id,
|
||||
staffId,
|
||||
businessId: business.businessId,
|
||||
});
|
||||
expect(second.success).toBe(false);
|
||||
expect(second.validation.issues[0]?.code).toBe("REWARD_NOT_AVAILABLE");
|
||||
|
||||
// Stamps weren't deducted a second time.
|
||||
const account2 = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: account.id } });
|
||||
expect(account2.currentStamps).toBe(0);
|
||||
|
||||
// The second appointment was never discounted.
|
||||
const unaffected = await prisma.appointment.findUniqueOrThrow({ where: { id: secondAppointment.id } });
|
||||
expect(unaffected.discountAmount).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects applying a reward to an appointment that already has a discount", async () => {
|
||||
const { staffId, reward, appointment } = await setUpRedeemableReward();
|
||||
await prisma.appointment.update({ where: { id: appointment.id }, data: { discountAmount: 5 } });
|
||||
|
||||
const result = await redeemReward(prisma, {
|
||||
rewardId: reward.id,
|
||||
appointmentId: appointment.id,
|
||||
staffId,
|
||||
businessId: business.businessId,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.validation.issues[0]?.code).toBe("APPOINTMENT_NOT_ELIGIBLE");
|
||||
});
|
||||
|
||||
it("rejects applying a reward to a different customer's appointment", async () => {
|
||||
const { staffId, reward } = await setUpRedeemableReward();
|
||||
const { customerId: otherCustomerId } = await createTestCustomer(business);
|
||||
const otherAppointment = await prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId: otherCustomerId,
|
||||
status: "CONFIRMED",
|
||||
startAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
endAt: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60_000),
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await redeemReward(prisma, {
|
||||
rewardId: reward.id,
|
||||
appointmentId: otherAppointment.id,
|
||||
staffId,
|
||||
businessId: business.businessId,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.validation.issues[0]?.code).toBe("REWARD_CUSTOMER_MISMATCH");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,11 @@ import type { ValidationResult } from "@/lib/validation-result";
|
||||
* without creating the matching transaction in the same call, and nothing
|
||||
* outside this file should touch either table directly.
|
||||
*
|
||||
* Reward reset-on-redemption (see LoyaltyReward's own schema comment) is
|
||||
* deliberately not implemented here — redeeming a reward isn't one of the
|
||||
* Phase 5 barber capabilities, so `currentStamps` is allowed to sit at or
|
||||
* above `stampsRequired` once a reward becomes available. Redemption is a
|
||||
* documented Phase 5 limitation, not an oversight.
|
||||
* Reward redemption (Phase 6) follows the same discipline: redeeming a
|
||||
* reward, deducting the consumed stamps from the cached total, and
|
||||
* recording the ledger entry all happen inside one `$transaction` — see
|
||||
* `redeemReward`. This was a documented, deliberate Phase 5 limitation,
|
||||
* not an oversight; it's implemented now.
|
||||
*/
|
||||
|
||||
export type LoyaltyErrorCode =
|
||||
@@ -24,7 +24,15 @@ export type LoyaltyErrorCode =
|
||||
| "REASON_REQUIRED"
|
||||
| "INVALID_AMOUNT"
|
||||
| "APPOINTMENT_NOT_FOUND"
|
||||
| "APPOINTMENT_NOT_COMPLETED";
|
||||
| "APPOINTMENT_NOT_COMPLETED"
|
||||
| "INVALID_INPUT"
|
||||
| "INVALID_PERCENTAGE"
|
||||
| "INVALID_VALUE"
|
||||
| "FREE_SERVICE_REQUIRED"
|
||||
| "REWARD_NOT_FOUND"
|
||||
| "REWARD_NOT_AVAILABLE"
|
||||
| "REWARD_CUSTOMER_MISMATCH"
|
||||
| "APPOINTMENT_NOT_ELIGIBLE";
|
||||
|
||||
export interface LoyaltySummary {
|
||||
isEnabled: boolean;
|
||||
@@ -451,3 +459,284 @@ export async function getLoyaltyHistory(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Owner: programme configuration ─────────────────────────────────────
|
||||
|
||||
export interface ProgrammeConfigInput {
|
||||
isEnabled: boolean;
|
||||
stampsRequired: number;
|
||||
rewardType: "PERCENTAGE" | "FIXED_AMOUNT" | "FREE_SERVICE";
|
||||
/** Decimal string — see ServiceCatalogueService's file header for why money is never a JS number. Required for PERCENTAGE/FIXED_AMOUNT, ignored for FREE_SERVICE. */
|
||||
rewardValue?: string;
|
||||
rewardLabel?: string;
|
||||
maxRewardValue?: string;
|
||||
freeServiceId?: string;
|
||||
autoApplyReward: boolean;
|
||||
requiresStaffApproval: boolean;
|
||||
stampsExpireAfterDays?: number;
|
||||
rewardExpiresAfterDays?: number;
|
||||
earnOnCancelled: boolean;
|
||||
earnOnNoShow: boolean;
|
||||
cardBackgroundColor?: string;
|
||||
cardBackgroundImageUrl?: string;
|
||||
stampIcon?: string;
|
||||
termsLine?: string;
|
||||
}
|
||||
|
||||
/** Only enforced when isEnabled — a half-configured, disabled draft is fine (the owner is still working on it); a half-configured, *enabled* programme would start awarding rewards nobody can redeem correctly. */
|
||||
export function validateProgrammeConfig(input: ProgrammeConfigInput): ValidationResult<LoyaltyErrorCode> {
|
||||
if (!Number.isInteger(input.stampsRequired) || input.stampsRequired < 1) {
|
||||
return fail({ code: "INVALID_INPUT", message: "Stamps required must be at least 1." });
|
||||
}
|
||||
if (!input.isEnabled) return ok();
|
||||
|
||||
if (input.rewardType === "PERCENTAGE") {
|
||||
const value = input.rewardValue !== undefined ? Number(input.rewardValue) : NaN;
|
||||
if (!Number.isFinite(value) || value <= 0 || value > 100) {
|
||||
return fail({ code: "INVALID_PERCENTAGE", message: "Percentage must be between 1 and 100." });
|
||||
}
|
||||
} else if (input.rewardType === "FIXED_AMOUNT") {
|
||||
const value = input.rewardValue !== undefined ? Number(input.rewardValue) : NaN;
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
return fail({ code: "INVALID_VALUE", message: "Enter a fixed reward amount of zero or more." });
|
||||
}
|
||||
} else if (input.rewardType === "FREE_SERVICE" && !input.freeServiceId) {
|
||||
return fail({ code: "FREE_SERVICE_REQUIRED", message: "Choose which service is free." });
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
export async function getProgrammeConfig(prisma: PrismaClient, businessId: string) {
|
||||
return prisma.loyaltyProgramme.findUnique({
|
||||
where: { businessId },
|
||||
include: {
|
||||
eligibleServices: { include: { service: true } },
|
||||
eligibleStaff: true,
|
||||
freeService: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProgrammeConfig(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; input: ProgrammeConfigInput },
|
||||
): Promise<{ success: boolean; validation: ValidationResult<LoyaltyErrorCode> }> {
|
||||
const validation = validateProgrammeConfig(params.input);
|
||||
if (!validation.valid) return { success: false, validation };
|
||||
|
||||
if (params.input.isEnabled && params.input.rewardType === "FREE_SERVICE" && params.input.freeServiceId) {
|
||||
const service = await prisma.service.findFirst({
|
||||
where: { id: params.input.freeServiceId, businessId: params.businessId, isActive: true },
|
||||
});
|
||||
if (!service) {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({ code: "FREE_SERVICE_REQUIRED", message: "Choose an active service." }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const data = {
|
||||
isEnabled: params.input.isEnabled,
|
||||
stampsRequired: params.input.stampsRequired,
|
||||
rewardType: params.input.rewardType,
|
||||
rewardValue: params.input.rewardType === "FREE_SERVICE" ? null : (params.input.rewardValue ?? null),
|
||||
rewardLabel: params.input.rewardLabel?.trim() || null,
|
||||
maxRewardValue: params.input.maxRewardValue || null,
|
||||
freeServiceId: params.input.rewardType === "FREE_SERVICE" ? (params.input.freeServiceId ?? null) : null,
|
||||
autoApplyReward: params.input.autoApplyReward,
|
||||
requiresStaffApproval: params.input.requiresStaffApproval,
|
||||
stampsExpireAfterDays: params.input.stampsExpireAfterDays ?? null,
|
||||
rewardExpiresAfterDays: params.input.rewardExpiresAfterDays ?? null,
|
||||
earnOnCancelled: params.input.earnOnCancelled,
|
||||
earnOnNoShow: params.input.earnOnNoShow,
|
||||
cardBackgroundColor: params.input.cardBackgroundColor || null,
|
||||
cardBackgroundImageUrl: params.input.cardBackgroundImageUrl || null,
|
||||
stampIcon: params.input.stampIcon || null,
|
||||
termsLine: params.input.termsLine?.trim() || null,
|
||||
};
|
||||
|
||||
await prisma.loyaltyProgramme.upsert({
|
||||
where: { businessId: params.businessId },
|
||||
create: { businessId: params.businessId, ...data },
|
||||
update: data,
|
||||
});
|
||||
|
||||
return { success: true, validation: ok() };
|
||||
}
|
||||
|
||||
export async function setEligibleServices(
|
||||
prisma: PrismaClient,
|
||||
params: { loyaltyProgrammeId: string; serviceIds: string[] },
|
||||
): Promise<{ success: true }> {
|
||||
await prisma.$transaction([
|
||||
prisma.loyaltyEligibleService.deleteMany({ where: { loyaltyProgrammeId: params.loyaltyProgrammeId } }),
|
||||
prisma.loyaltyEligibleService.createMany({
|
||||
data: params.serviceIds.map((serviceId) => ({ loyaltyProgrammeId: params.loyaltyProgrammeId, serviceId })),
|
||||
}),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** LoyaltyEligibleStaff.staffId has no FK relation in the schema (see schema.prisma) — callers that need staff names for display fetch StaffProfile rows separately and join by id in memory. */
|
||||
export async function setEligibleStaff(
|
||||
prisma: PrismaClient,
|
||||
params: { loyaltyProgrammeId: string; staffIds: string[] },
|
||||
): Promise<{ success: true }> {
|
||||
await prisma.$transaction([
|
||||
prisma.loyaltyEligibleStaff.deleteMany({ where: { loyaltyProgrammeId: params.loyaltyProgrammeId } }),
|
||||
prisma.loyaltyEligibleStaff.createMany({
|
||||
data: params.staffIds.map((staffId) => ({ loyaltyProgrammeId: params.loyaltyProgrammeId, staffId })),
|
||||
}),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Reward redemption ───────────────────────────────────────────────────
|
||||
|
||||
/** Appointments this reward could be applied to right now — same customer, not cancelled, no reward already applied. Shown so staff pick a real, current appointment rather than typing an id. */
|
||||
export async function listEligibleAppointmentsForReward(
|
||||
prisma: PrismaClient,
|
||||
params: { customerId: string; businessId: string },
|
||||
) {
|
||||
return prisma.appointment.findMany({
|
||||
where: {
|
||||
customerId: params.customerId,
|
||||
businessId: params.businessId,
|
||||
status: { notIn: ["CANCELLED"] },
|
||||
discountAmount: null,
|
||||
},
|
||||
include: { services: { include: { service: true } }, staff: { include: { user: { select: { name: true } } } } },
|
||||
orderBy: { startAt: "desc" },
|
||||
take: 10,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeems an AVAILABLE reward against a specific appointment — the one
|
||||
* place `LoyaltyReward.status`, `LoyaltyAccount.currentStamps`, and
|
||||
* `Appointment.discountAmount` ever change together. The status flip uses
|
||||
* a conditional `updateMany` (`WHERE status = 'AVAILABLE'`) inside the
|
||||
* transaction rather than a read-then-write, so two simultaneous
|
||||
* redemption attempts can't both succeed — the loser sees
|
||||
* REWARD_NOT_AVAILABLE, not a corrupted double-deduction.
|
||||
*
|
||||
* Neither the reward nor the stamps-required count is snapshotted at
|
||||
* *earn* time — redemption always uses the loyalty programme's current
|
||||
* configuration (current stampsRequired, current freeServiceId for a
|
||||
* FREE_SERVICE reward). A documented simplification: if the owner
|
||||
* reconfigures the programme between a reward being earned and redeemed,
|
||||
* redemption reflects the new configuration. See docs/phase-6-summary.md.
|
||||
*/
|
||||
export async function redeemReward(
|
||||
prisma: PrismaClient,
|
||||
params: { rewardId: string; appointmentId: string; staffId: string; businessId: string },
|
||||
): Promise<{ success: boolean; validation: ValidationResult<LoyaltyErrorCode>; discountAmount?: string }> {
|
||||
const reward = await prisma.loyaltyReward.findUnique({
|
||||
where: { id: params.rewardId },
|
||||
include: { loyaltyAccount: { include: { loyaltyProgramme: true } } },
|
||||
});
|
||||
if (!reward || reward.loyaltyAccount.loyaltyProgramme.businessId !== params.businessId) {
|
||||
return { success: false, validation: fail({ code: "REWARD_NOT_FOUND", message: "Reward not found." }) };
|
||||
}
|
||||
if (reward.status !== "AVAILABLE") {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has already been redeemed or has expired." }),
|
||||
};
|
||||
}
|
||||
|
||||
const appointment = await prisma.appointment.findFirst({
|
||||
where: { id: params.appointmentId, businessId: params.businessId },
|
||||
include: { services: true },
|
||||
});
|
||||
if (!appointment) {
|
||||
return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }) };
|
||||
}
|
||||
if (appointment.customerId !== reward.loyaltyAccount.customerId) {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({ code: "REWARD_CUSTOMER_MISMATCH", message: "This reward belongs to a different customer." }),
|
||||
};
|
||||
}
|
||||
if (appointment.status === "CANCELLED") {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({ code: "APPOINTMENT_NOT_ELIGIBLE", message: "Can't apply a reward to a cancelled appointment." }),
|
||||
};
|
||||
}
|
||||
if (appointment.discountAmount !== null) {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({ code: "APPOINTMENT_NOT_ELIGIBLE", message: "A reward has already been applied to this appointment." }),
|
||||
};
|
||||
}
|
||||
|
||||
const subtotal = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
|
||||
let discountAmount: number;
|
||||
if (reward.rewardType === "PERCENTAGE") {
|
||||
discountAmount = Math.round(subtotal * (Number(reward.value ?? 0) / 100) * 100) / 100;
|
||||
} else if (reward.rewardType === "FIXED_AMOUNT") {
|
||||
discountAmount = Number(reward.value ?? 0);
|
||||
} else {
|
||||
const freeLine = appointment.services.find((s) => s.serviceId === reward.loyaltyAccount.loyaltyProgramme.freeServiceId);
|
||||
if (!freeLine) {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({
|
||||
code: "APPOINTMENT_NOT_ELIGIBLE",
|
||||
message: "This appointment doesn't include the service this reward covers.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
discountAmount = Number(freeLine.priceAtBooking);
|
||||
}
|
||||
discountAmount = Math.min(Math.max(discountAmount, 0), subtotal);
|
||||
const discountAmountString = discountAmount.toFixed(2);
|
||||
|
||||
const stampsRequired = reward.loyaltyAccount.loyaltyProgramme.stampsRequired;
|
||||
|
||||
const { alreadyRedeemed } = await prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.loyaltyReward.updateMany({
|
||||
where: { id: params.rewardId, status: "AVAILABLE" },
|
||||
data: {
|
||||
status: "REDEEMED",
|
||||
redeemedAt: new Date(),
|
||||
redeemedByStaffId: params.staffId,
|
||||
appliedToAppointmentId: params.appointmentId,
|
||||
},
|
||||
});
|
||||
if (updated.count === 0) return { alreadyRedeemed: true };
|
||||
|
||||
const account = await tx.loyaltyAccount.findUniqueOrThrow({ where: { id: reward.loyaltyAccountId } });
|
||||
const clampedDeduction = Math.min(stampsRequired, account.currentStamps);
|
||||
await tx.loyaltyAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { currentStamps: { decrement: clampedDeduction } },
|
||||
});
|
||||
await tx.loyaltyTransaction.create({
|
||||
data: {
|
||||
loyaltyAccountId: account.id,
|
||||
type: "REWARD_REDEEMED",
|
||||
amount: -clampedDeduction,
|
||||
appointmentId: params.appointmentId,
|
||||
authoredByStaffId: params.staffId,
|
||||
},
|
||||
});
|
||||
await tx.appointment.update({
|
||||
where: { id: params.appointmentId },
|
||||
data: { discountAmount: discountAmountString },
|
||||
});
|
||||
|
||||
return { alreadyRedeemed: false };
|
||||
});
|
||||
|
||||
if (alreadyRedeemed) {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has already been redeemed." }),
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, validation: ok(), discountAmount: discountAmountString };
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import {
|
||||
calendarDateToUtcMidnight,
|
||||
minutesFromMidnight,
|
||||
weekdayOf,
|
||||
} from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import { validateProgrammeConfig } from "@/services/loyalty/loyalty.service";
|
||||
import { listAppointments } from "@/services/scheduling/appointment.service";
|
||||
|
||||
/**
|
||||
* Read-only aggregation for the owner dashboard — every function here
|
||||
* reads across services/appointments/staff and returns a typed summary;
|
||||
* nothing in this file writes anything. Per the brief: only exists because
|
||||
* the dashboard's queries are genuinely more than "call one existing
|
||||
* service and render it" — this is not a general owner data-access layer,
|
||||
* and no other feature should import from it.
|
||||
*/
|
||||
|
||||
const ACTIVE_STATUSES = ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"] as const;
|
||||
|
||||
export interface DashboardTotals {
|
||||
total: number;
|
||||
completed: number;
|
||||
cancelled: number;
|
||||
noShow: number;
|
||||
inProgress: number;
|
||||
upcoming: number;
|
||||
}
|
||||
|
||||
export interface PopularService {
|
||||
serviceId: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ConfigWarning {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DashboardSummary {
|
||||
totals: DashboardTotals;
|
||||
staffWorkingToday: { staffId: string; name: string }[];
|
||||
staffOnTimeOffToday: { staffId: string; name: string; reason: string | null }[];
|
||||
/** Booked minutes vs. total working minutes across staff working today — an estimate, not a financial figure, and always labelled as such by the caller. Null when no staff are working today (division by zero). */
|
||||
occupancy: { bookedMinutes: number; availableMinutes: number; percent: number } | null;
|
||||
popularServices: PopularService[];
|
||||
recentLoyaltyActivity: Awaited<ReturnType<typeof getRecentLoyaltyActivity>>;
|
||||
customersWithAvailableRewards: number;
|
||||
warnings: ConfigWarning[];
|
||||
}
|
||||
|
||||
async function getRecentLoyaltyActivity(prisma: PrismaClient, businessId: string, limit: number) {
|
||||
return prisma.loyaltyTransaction.findMany({
|
||||
where: { loyaltyAccount: { loyaltyProgramme: { businessId } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
include: {
|
||||
loyaltyAccount: { include: { customer: { select: { firstName: true, lastName: true } } } },
|
||||
authoredByStaff: { include: { user: { select: { name: true } } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDashboardSummary(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; businessTimezone: string; date: CalendarDate },
|
||||
): Promise<DashboardSummary> {
|
||||
const appointments = await listAppointments(prisma, {
|
||||
businessId: params.businessId,
|
||||
fromDate: params.date,
|
||||
toDate: params.date,
|
||||
businessTimezone: params.businessTimezone,
|
||||
});
|
||||
|
||||
const totals: DashboardTotals = {
|
||||
total: appointments.length,
|
||||
completed: appointments.filter((a) => a.status === "COMPLETED").length,
|
||||
cancelled: appointments.filter((a) => a.status === "CANCELLED").length,
|
||||
noShow: appointments.filter((a) => a.status === "NO_SHOW").length,
|
||||
inProgress: appointments.filter((a) => a.status === "IN_PROGRESS").length,
|
||||
upcoming: appointments.filter((a) => (ACTIVE_STATUSES as readonly string[]).includes(a.status)).length,
|
||||
};
|
||||
|
||||
const weekday = weekdayOf(params.date);
|
||||
const dateUtcMidnight = calendarDateToUtcMidnight(params.date);
|
||||
|
||||
const [activeStaff, workingHoursToday, timeOffToday] = await Promise.all([
|
||||
prisma.staffProfile.findMany({
|
||||
where: { businessId: params.businessId, status: "ACTIVE" },
|
||||
include: { user: { select: { name: true } } },
|
||||
}),
|
||||
prisma.workingHours.findMany({
|
||||
where: { staff: { businessId: params.businessId, status: "ACTIVE" }, dayOfWeek: weekday },
|
||||
}),
|
||||
prisma.timeOff.findMany({
|
||||
where: {
|
||||
staff: { businessId: params.businessId, status: "ACTIVE" },
|
||||
status: "APPROVED",
|
||||
startMinutes: null,
|
||||
startDate: { lte: dateUtcMidnight },
|
||||
endDate: { gte: dateUtcMidnight },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const workingHoursByStaffId = new Map(workingHoursToday.map((h) => [h.staffId, h]));
|
||||
const timeOffStaffIds = new Set(timeOffToday.map((t) => t.staffId));
|
||||
const nameByStaffId = new Map(activeStaff.map((s) => [s.id, s.user.name ?? "Staff"]));
|
||||
|
||||
const staffWorkingToday = activeStaff
|
||||
.filter((s) => {
|
||||
const hours = workingHoursByStaffId.get(s.id);
|
||||
return hours && !hours.isOff && !timeOffStaffIds.has(s.id);
|
||||
})
|
||||
.map((s) => ({ staffId: s.id, name: nameByStaffId.get(s.id) ?? "Staff" }));
|
||||
|
||||
const staffOnTimeOffToday = timeOffToday.map((t) => ({
|
||||
staffId: t.staffId,
|
||||
name: nameByStaffId.get(t.staffId) ?? "Staff",
|
||||
reason: t.reason,
|
||||
}));
|
||||
|
||||
let occupancy: DashboardSummary["occupancy"] = null;
|
||||
if (staffWorkingToday.length > 0) {
|
||||
const availableMinutes = staffWorkingToday.reduce((sum, s) => {
|
||||
const hours = workingHoursByStaffId.get(s.staffId);
|
||||
if (!hours || hours.startMinutes === null || hours.endMinutes === null) return sum;
|
||||
return sum + (hours.endMinutes - hours.startMinutes);
|
||||
}, 0);
|
||||
const bookedMinutes = appointments
|
||||
.filter((a) => (ACTIVE_STATUSES as readonly string[]).includes(a.status) || a.status === "COMPLETED")
|
||||
.reduce((sum, a) => sum + minutesFromMidnight(a.endAt, params.businessTimezone) - minutesFromMidnight(a.startAt, params.businessTimezone), 0);
|
||||
occupancy = {
|
||||
bookedMinutes,
|
||||
availableMinutes,
|
||||
percent: availableMinutes > 0 ? Math.round((bookedMinutes / availableMinutes) * 100) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const recentServiceLines = await prisma.appointmentService.findMany({
|
||||
where: {
|
||||
appointment: { businessId: params.businessId, status: "COMPLETED", startAt: { gte: thirtyDaysAgo } },
|
||||
},
|
||||
include: { service: { select: { id: true, name: true } } },
|
||||
});
|
||||
const serviceCounts = new Map<string, PopularService>();
|
||||
for (const line of recentServiceLines) {
|
||||
const existing = serviceCounts.get(line.serviceId);
|
||||
if (existing) existing.count += 1;
|
||||
else serviceCounts.set(line.serviceId, { serviceId: line.serviceId, name: line.service.name, count: 1 });
|
||||
}
|
||||
const popularServices = [...serviceCounts.values()].sort((a, b) => b.count - a.count).slice(0, 5);
|
||||
|
||||
const [recentLoyaltyActivity, customersWithAvailableRewards, warnings] = await Promise.all([
|
||||
getRecentLoyaltyActivity(prisma, params.businessId, 5),
|
||||
prisma.loyaltyReward.count({
|
||||
where: { status: "AVAILABLE", loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } } },
|
||||
}),
|
||||
getConfigurationWarnings(prisma, params.businessId),
|
||||
]);
|
||||
|
||||
return {
|
||||
totals,
|
||||
staffWorkingToday,
|
||||
staffOnTimeOffToday,
|
||||
occupancy,
|
||||
popularServices,
|
||||
recentLoyaltyActivity,
|
||||
customersWithAvailableRewards,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getConfigurationWarnings(prisma: PrismaClient, businessId: string): Promise<ConfigWarning[]> {
|
||||
const warnings: ConfigWarning[] = [];
|
||||
|
||||
const [businessHours, activeStaff, activeServices, loyaltyProgramme] = await Promise.all([
|
||||
prisma.businessHours.findMany({ where: { businessId } }),
|
||||
prisma.staffProfile.findMany({
|
||||
where: { businessId, status: "ACTIVE" },
|
||||
include: { user: { select: { name: true } }, workingHours: true, services: true },
|
||||
}),
|
||||
prisma.service.findMany({
|
||||
where: { businessId, isActive: true },
|
||||
include: { staff: { include: { staff: { select: { status: true } } } } },
|
||||
}),
|
||||
prisma.loyaltyProgramme.findUnique({ where: { businessId } }),
|
||||
]);
|
||||
|
||||
if (businessHours.length === 0 || businessHours.every((h) => h.isClosed)) {
|
||||
warnings.push({ code: "NO_OPENING_HOURS", message: "The business has no open days configured." });
|
||||
}
|
||||
|
||||
for (const staff of activeStaff) {
|
||||
const hasAnyOpenDay = staff.workingHours.some((h) => !h.isOff);
|
||||
if (!hasAnyOpenDay) {
|
||||
warnings.push({
|
||||
code: "STAFF_NO_WORKING_HOURS",
|
||||
message: `${staff.user.name ?? "A staff member"} has no working hours set — they can never be booked.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const service of activeServices) {
|
||||
const hasActiveEligibleStaff = service.staff.some((s) => s.staff.status === "ACTIVE");
|
||||
if (!hasActiveEligibleStaff) {
|
||||
warnings.push({
|
||||
code: "SERVICE_NO_ELIGIBLE_STAFF",
|
||||
message: `"${service.name}" is active but no active staff member is eligible to perform it.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (loyaltyProgramme?.isEnabled) {
|
||||
const validation = validateProgrammeConfig({
|
||||
isEnabled: loyaltyProgramme.isEnabled,
|
||||
stampsRequired: loyaltyProgramme.stampsRequired,
|
||||
rewardType: loyaltyProgramme.rewardType,
|
||||
rewardValue: loyaltyProgramme.rewardValue?.toString(),
|
||||
freeServiceId: loyaltyProgramme.freeServiceId ?? undefined,
|
||||
autoApplyReward: loyaltyProgramme.autoApplyReward,
|
||||
requiresStaffApproval: loyaltyProgramme.requiresStaffApproval,
|
||||
earnOnCancelled: loyaltyProgramme.earnOnCancelled,
|
||||
earnOnNoShow: loyaltyProgramme.earnOnNoShow,
|
||||
});
|
||||
if (!validation.valid) {
|
||||
warnings.push({
|
||||
code: "LOYALTY_INCOMPLETE",
|
||||
message: `The loyalty programme is enabled but incomplete: ${validation.issues[0]?.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const upcomingTimeOffConflicts = await findUpcomingTimeOffConflicts(prisma, businessId);
|
||||
warnings.push(...upcomingTimeOffConflicts);
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/** Whole-day or partial-day time off that overlaps a future, non-cancelled appointment for the same staff member — surfaced as a dashboard warning rather than blocking anything, since the time off may already have been added deliberately (e.g. via Phase 5's own conflict-acknowledge flow). */
|
||||
async function findUpcomingTimeOffConflicts(prisma: PrismaClient, businessId: string): Promise<ConfigWarning[]> {
|
||||
const upcomingTimeOff = await prisma.timeOff.findMany({
|
||||
where: {
|
||||
staff: { businessId },
|
||||
status: "APPROVED",
|
||||
endDate: { gte: calendarDateToUtcMidnight(new Date().toISOString().slice(0, 10)) },
|
||||
},
|
||||
include: { staff: { include: { user: { select: { name: true } } } } },
|
||||
});
|
||||
if (upcomingTimeOff.length === 0) return [];
|
||||
|
||||
const warnings: ConfigWarning[] = [];
|
||||
for (const timeOff of upcomingTimeOff) {
|
||||
const conflictCount = await prisma.appointment.count({
|
||||
where: {
|
||||
staffId: timeOff.staffId,
|
||||
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
|
||||
startAt: { lt: timeOff.endDate, gte: timeOff.startDate },
|
||||
},
|
||||
});
|
||||
if (conflictCount > 0) {
|
||||
warnings.push({
|
||||
code: "TIME_OFF_CONFLICT",
|
||||
message: `${timeOff.staff.user.name ?? "A staff member"} has ${conflictCount} upcoming appointment${conflictCount === 1 ? "" : "s"} during approved time off.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateBookingRuleInput } from "../booking-rule.service";
|
||||
import type { BookingRuleInput } from "../booking-rule.service";
|
||||
|
||||
const base: BookingRuleInput = {
|
||||
minNoticeMinutes: 60,
|
||||
maxAdvanceDays: 30,
|
||||
cancellationWindowHours: 24,
|
||||
rescheduleWindowHours: 24,
|
||||
slotIntervalMinutes: 15,
|
||||
bufferBeforeMinutes: 0,
|
||||
bufferAfterMinutes: 0,
|
||||
maxAppointmentsPerDay: null,
|
||||
maxAppointmentsPerStaffPerDay: null,
|
||||
customerCanChooseStaff: true,
|
||||
allowGuestBooking: true,
|
||||
requireApproval: false,
|
||||
};
|
||||
|
||||
describe("validateBookingRuleInput", () => {
|
||||
it("accepts the default configuration", () => {
|
||||
expect(validateBookingRuleInput(base).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a negative minimum notice", () => {
|
||||
const result = validateBookingRuleInput({ ...base, minNoticeMinutes: -1 });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
});
|
||||
|
||||
it("rejects a maximum advance window under 1 day", () => {
|
||||
const result = validateBookingRuleInput({ ...base, maxAdvanceDays: 0 });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a negative cancellation window", () => {
|
||||
expect(validateBookingRuleInput({ ...base, cancellationWindowHours: -1 }).valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a negative reschedule window", () => {
|
||||
expect(validateBookingRuleInput({ ...base, rescheduleWindowHours: -1 }).valid).toBe(false);
|
||||
});
|
||||
|
||||
it.each([4, 121])("rejects a slot interval outside 5-120 minutes (%s)", (slotIntervalMinutes) => {
|
||||
expect(validateBookingRuleInput({ ...base, slotIntervalMinutes }).valid).toBe(false);
|
||||
});
|
||||
|
||||
it.each([5, 120])("accepts slot interval boundaries (%s)", (slotIntervalMinutes) => {
|
||||
expect(validateBookingRuleInput({ ...base, slotIntervalMinutes }).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a negative buffer", () => {
|
||||
expect(validateBookingRuleInput({ ...base, bufferBeforeMinutes: -5 }).valid).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts null max-appointments-per-day (unlimited)", () => {
|
||||
expect(validateBookingRuleInput({ ...base, maxAppointmentsPerDay: null }).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a zero max-appointments-per-day", () => {
|
||||
expect(validateBookingRuleInput({ ...base, maxAppointmentsPerDay: 0 }).valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a zero max-appointments-per-staff-per-day", () => {
|
||||
expect(validateBookingRuleInput({ ...base, maxAppointmentsPerStaffPerDay: 0 }).valid).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a positive max-appointments-per-staff-per-day", () => {
|
||||
expect(validateBookingRuleInput({ ...base, maxAppointmentsPerStaffPerDay: 5 }).valid).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { fail, ok } from "@/lib/validation-result";
|
||||
import type { ValidationResult } from "@/lib/validation-result";
|
||||
import type { BookingRuleConfig } from "./business-rules.service";
|
||||
|
||||
/**
|
||||
* Read/write for the BookingRule row itself — deliberately separate from
|
||||
* business-rules.service.ts, which is pure evaluation logic with no Prisma
|
||||
* access by design (see that file's own header). This file owns the one
|
||||
* thing that one doesn't: loading and validating the actual configuration
|
||||
* an owner edits, which every booking-time check then reads via
|
||||
* AvailabilityService exactly as before — nothing about how rules are
|
||||
* *evaluated* changes here, only how they're *set*.
|
||||
*/
|
||||
|
||||
export type BookingRuleErrorCode = "INVALID_INPUT";
|
||||
|
||||
export type BookingRuleInput = BookingRuleConfig;
|
||||
|
||||
export function validateBookingRuleInput(input: BookingRuleInput): ValidationResult<BookingRuleErrorCode> {
|
||||
if (!Number.isInteger(input.minNoticeMinutes) || input.minNoticeMinutes < 0) {
|
||||
return fail({ code: "INVALID_INPUT", message: "Minimum notice can't be negative." });
|
||||
}
|
||||
if (!Number.isInteger(input.maxAdvanceDays) || input.maxAdvanceDays < 1) {
|
||||
return fail({ code: "INVALID_INPUT", message: "Maximum advance booking must be at least 1 day." });
|
||||
}
|
||||
if (!Number.isInteger(input.cancellationWindowHours) || input.cancellationWindowHours < 0) {
|
||||
return fail({ code: "INVALID_INPUT", message: "Cancellation window can't be negative." });
|
||||
}
|
||||
if (!Number.isInteger(input.rescheduleWindowHours) || input.rescheduleWindowHours < 0) {
|
||||
return fail({ code: "INVALID_INPUT", message: "Reschedule window can't be negative." });
|
||||
}
|
||||
if (!Number.isInteger(input.slotIntervalMinutes) || input.slotIntervalMinutes < 5 || input.slotIntervalMinutes > 120) {
|
||||
return fail({ code: "INVALID_INPUT", message: "Slot interval must be between 5 and 120 minutes." });
|
||||
}
|
||||
if (!Number.isInteger(input.bufferBeforeMinutes) || input.bufferBeforeMinutes < 0) {
|
||||
return fail({ code: "INVALID_INPUT", message: "Buffer before can't be negative." });
|
||||
}
|
||||
if (!Number.isInteger(input.bufferAfterMinutes) || input.bufferAfterMinutes < 0) {
|
||||
return fail({ code: "INVALID_INPUT", message: "Buffer after can't be negative." });
|
||||
}
|
||||
if (input.maxAppointmentsPerDay !== null && (!Number.isInteger(input.maxAppointmentsPerDay) || input.maxAppointmentsPerDay < 1)) {
|
||||
return fail({ code: "INVALID_INPUT", message: "Max appointments per day must be at least 1, or left unlimited." });
|
||||
}
|
||||
if (
|
||||
input.maxAppointmentsPerStaffPerDay !== null &&
|
||||
(!Number.isInteger(input.maxAppointmentsPerStaffPerDay) || input.maxAppointmentsPerStaffPerDay < 1)
|
||||
) {
|
||||
return fail({ code: "INVALID_INPUT", message: "Max appointments per staff member per day must be at least 1, or left unlimited." });
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
export async function getBookingRule(prisma: PrismaClient, businessId: string) {
|
||||
return prisma.bookingRule.findUnique({ where: { businessId } });
|
||||
}
|
||||
|
||||
export async function updateBookingRule(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; input: BookingRuleInput },
|
||||
): Promise<{ success: boolean; validation: ValidationResult<BookingRuleErrorCode> }> {
|
||||
const validation = validateBookingRuleInput(params.input);
|
||||
if (!validation.valid) return { success: false, validation };
|
||||
|
||||
await prisma.bookingRule.upsert({
|
||||
where: { businessId: params.businessId },
|
||||
create: { businessId: params.businessId, ...params.input },
|
||||
update: { ...params.input },
|
||||
});
|
||||
|
||||
return { success: true, validation: ok() };
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createStaffProfile,
|
||||
deactivateStaff,
|
||||
reactivateStaff,
|
||||
setStaffServiceEligibility,
|
||||
updateStaffAsOwner,
|
||||
} from "../staff.service";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
|
||||
/**
|
||||
* Covers the owner-only staff management additions from Phase 6:
|
||||
* onboarding, deactivation/last-owner protection, and service-eligibility
|
||||
* conflict review. Uses createTestStaff's auto-created role (no
|
||||
* MANAGE_STAFF permission) as the "ordinary barber" role, and a
|
||||
* hand-created role with MANAGE_STAFF as the "owner" role — mirroring how
|
||||
* countOtherActiveManagers/staffCurrentlyManagesStaff actually define "is
|
||||
* an owner" (see staff.service.ts).
|
||||
*/
|
||||
describe("StaffService — owner actions (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
async function createOwnerRole() {
|
||||
return prisma.role.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
name: "Owner",
|
||||
permissions: { create: [{ permission: "MANAGE_STAFF" }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createBarberRole() {
|
||||
return prisma.role.create({ data: { businessId: business.businessId, name: "Barber" } });
|
||||
}
|
||||
|
||||
describe("createStaffProfile", () => {
|
||||
it("creates a fully usable User + StaffProfile + eligibility in one go", async () => {
|
||||
const role = await createBarberRole();
|
||||
const result = await createStaffProfile(prisma, {
|
||||
businessId: business.businessId,
|
||||
input: {
|
||||
name: "New Barber",
|
||||
email: "new.barber@test.local",
|
||||
roleId: role.id,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
isBookableOnline: true,
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) throw new Error("unreachable");
|
||||
business.createdUserIds.push(result.staff.userId);
|
||||
|
||||
const eligibility = await prisma.staffService.findMany({ where: { staffId: result.staff.id } });
|
||||
expect(eligibility.map((e) => e.serviceId)).toEqual([business.haircutServiceId]);
|
||||
});
|
||||
|
||||
it("rejects an email already in use", async () => {
|
||||
const role = await createBarberRole();
|
||||
const { userId } = await createTestStaff(business);
|
||||
const existingUser = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
|
||||
const result = await createStaffProfile(prisma, {
|
||||
businessId: business.businessId,
|
||||
input: {
|
||||
name: "Duplicate",
|
||||
email: existingUser.email!,
|
||||
roleId: role.id,
|
||||
serviceIds: [],
|
||||
isBookableOnline: true,
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) throw new Error("unreachable");
|
||||
expect(result.validation.issues[0]?.code).toBe("EMAIL_ALREADY_IN_USE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("last-owner protection", () => {
|
||||
it("blocks deactivating the only active owner", async () => {
|
||||
const ownerRole = await createOwnerRole();
|
||||
const { staffId: ownerStaffId, userId } = await createTestStaff(business);
|
||||
await prisma.staffProfile.update({ where: { id: ownerStaffId }, data: { roleId: ownerRole.id } });
|
||||
void userId;
|
||||
|
||||
const result = await deactivateStaff(prisma, { staffId: ownerStaffId, businessId: business.businessId });
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) throw new Error("unreachable");
|
||||
expect(result.validation.issues[0]?.code).toBe("LAST_OWNER");
|
||||
|
||||
const stillActive = await prisma.staffProfile.findUniqueOrThrow({ where: { id: ownerStaffId } });
|
||||
expect(stillActive.status).toBe("ACTIVE");
|
||||
});
|
||||
|
||||
it("allows deactivating an owner when another active owner remains", async () => {
|
||||
const ownerRole = await createOwnerRole();
|
||||
const { staffId: firstOwnerId } = await createTestStaff(business);
|
||||
const { staffId: secondOwnerId } = await createTestStaff(business);
|
||||
await prisma.staffProfile.updateMany({
|
||||
where: { id: { in: [firstOwnerId, secondOwnerId] } },
|
||||
data: { roleId: ownerRole.id },
|
||||
});
|
||||
|
||||
const result = await deactivateStaff(prisma, { staffId: firstOwnerId, businessId: business.businessId });
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const deactivated = await prisma.staffProfile.findUniqueOrThrow({ where: { id: firstOwnerId } });
|
||||
expect(deactivated.status).toBe("INACTIVE");
|
||||
});
|
||||
|
||||
it("blocks demoting the only active owner's role away from MANAGE_STAFF", async () => {
|
||||
const ownerRole = await createOwnerRole();
|
||||
const barberRole = await createBarberRole();
|
||||
const { staffId: ownerStaffId } = await createTestStaff(business);
|
||||
await prisma.staffProfile.update({ where: { id: ownerStaffId }, data: { roleId: ownerRole.id } });
|
||||
|
||||
const result = await updateStaffAsOwner(prisma, {
|
||||
staffId: ownerStaffId,
|
||||
businessId: business.businessId,
|
||||
input: { roleId: barberRole.id },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) throw new Error("unreachable");
|
||||
expect(result.validation.issues[0]?.code).toBe("LAST_OWNER");
|
||||
|
||||
const stillOwner = await prisma.staffProfile.findUniqueOrThrow({ where: { id: ownerStaffId } });
|
||||
expect(stillOwner.roleId).toBe(ownerRole.id);
|
||||
});
|
||||
|
||||
it("reactivating a deactivated staff member restores ACTIVE status", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
await prisma.staffProfile.update({ where: { id: staffId }, data: { status: "INACTIVE" } });
|
||||
|
||||
const result = await reactivateStaff(prisma, { staffId, businessId: business.businessId });
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const staff = await prisma.staffProfile.findUniqueOrThrow({ where: { id: staffId } });
|
||||
expect(staff.status).toBe("ACTIVE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deactivation appointment conflicts", () => {
|
||||
it("blocks deactivating a staff member with a future appointment, and Save Anyway bypasses it without cancelling it", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const appointment = await prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId,
|
||||
status: "CONFIRMED",
|
||||
startAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000),
|
||||
endAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000 + 30 * 60_000),
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const blocked = await deactivateStaff(prisma, { staffId, businessId: business.businessId });
|
||||
expect(blocked.success).toBe(false);
|
||||
if (blocked.success) throw new Error("unreachable");
|
||||
expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id);
|
||||
|
||||
const acknowledged = await deactivateStaff(prisma, {
|
||||
staffId,
|
||||
businessId: business.businessId,
|
||||
acknowledgeConflicts: true,
|
||||
});
|
||||
expect(acknowledged.success).toBe(true);
|
||||
|
||||
const stillThere = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
|
||||
expect(stillThere.status).toBe("CONFIRMED");
|
||||
expect(stillThere.staffId).toBe(staffId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setStaffServiceEligibility", () => {
|
||||
it("blocks removing a service that has a future appointment booked, and Save Anyway bypasses it", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId,
|
||||
customerId,
|
||||
status: "CONFIRMED",
|
||||
startAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000),
|
||||
endAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000 + 30 * 60_000),
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Existing fixture staff is eligible for both haircut and beard trim
|
||||
// (see createTestStaff) — removing haircut should conflict.
|
||||
const blocked = await setStaffServiceEligibility(prisma, {
|
||||
staffId,
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.beardTrimServiceId],
|
||||
});
|
||||
expect(blocked.success).toBe(false);
|
||||
if (blocked.success) throw new Error("unreachable");
|
||||
expect(blocked.conflicts.length).toBeGreaterThan(0);
|
||||
|
||||
const acknowledged = await setStaffServiceEligibility(prisma, {
|
||||
staffId,
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.beardTrimServiceId],
|
||||
acknowledgeConflicts: true,
|
||||
});
|
||||
expect(acknowledged.success).toBe(true);
|
||||
|
||||
const eligibility = await prisma.staffService.findMany({ where: { staffId } });
|
||||
expect(eligibility.map((e) => e.serviceId)).toEqual([business.beardTrimServiceId]);
|
||||
});
|
||||
|
||||
it("adding a service never triggers a conflict", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const result = await setStaffServiceEligibility(prisma, {
|
||||
staffId,
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId, business.beardTrimServiceId],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
StaffPermissionDeniedError,
|
||||
assertOwnsResource,
|
||||
validateBreakInput,
|
||||
validateCreateStaffInput,
|
||||
validateTimeOffInput,
|
||||
validateWorkingHoursInput,
|
||||
} from "../staff.service";
|
||||
@@ -135,3 +136,38 @@ describe("validateTimeOffInput", () => {
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCreateStaffInput", () => {
|
||||
const base = {
|
||||
name: "Owen Clarke",
|
||||
email: "owen.clarke@blakeheritage.co.uk",
|
||||
roleId: "role-barber",
|
||||
serviceIds: [] as string[],
|
||||
isBookableOnline: true,
|
||||
};
|
||||
|
||||
it("accepts a valid new-staff input", () => {
|
||||
expect(validateCreateStaffInput(base).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an empty name", () => {
|
||||
const result = validateCreateStaffInput({ ...base, name: " " });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
});
|
||||
|
||||
it.each(["not-an-email", "missing-domain@", "@missing-local.com", ""])(
|
||||
"rejects an invalid email %s",
|
||||
(email) => {
|
||||
const result = validateCreateStaffInput({ ...base, email });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a missing role", () => {
|
||||
const result = validateCreateStaffInput({ ...base, roleId: "" });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* The "invite a new staff member" flow, deliberately split out of
|
||||
* StaffService (see docs/phase-6-summary.md, "Domain services" — the
|
||||
* brief's own instruction not to mix invitation delivery into StaffService).
|
||||
* StaffService.createStaffProfile makes the User/StaffProfile usable
|
||||
* immediately; this file only tracks the separate "has this person set
|
||||
* their own password yet" signal via StaffInvitation.acceptedAt.
|
||||
*
|
||||
* No real email delivery this phase — `token` is a plain opaque value
|
||||
* (crypto-random, not HMAC-signed like the booking-draft cookie, because
|
||||
* it's validated against a database row rather than needing to be
|
||||
* self-contained) surfaced to the owner as a copy-link
|
||||
* (`/invite/accept?token=...`) to send however they like. This is a
|
||||
* documented development-safe shortcut, not a production email system.
|
||||
*/
|
||||
|
||||
const INVITATION_TTL_DAYS = 7;
|
||||
|
||||
export type StaffInvitationErrorCode = "INVALID_TOKEN" | "EXPIRED" | "ALREADY_ACCEPTED" | "INVALID_INPUT";
|
||||
|
||||
function generateToken(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
export async function createInvitation(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; staffId: string; email: string; invitedByUserId?: string },
|
||||
) {
|
||||
return prisma.staffInvitation.create({
|
||||
data: {
|
||||
businessId: params.businessId,
|
||||
staffId: params.staffId,
|
||||
email: params.email,
|
||||
token: generateToken(),
|
||||
invitedByUserId: params.invitedByUserId,
|
||||
expiresAt: new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** An expired or already-viewed invitation gets a fresh token/expiry rather than a second row — one StaffInvitation per StaffProfile (see the schema's `staffId @unique`). */
|
||||
export async function regenerateInvitation(
|
||||
prisma: PrismaClient,
|
||||
params: { staffId: string; businessId: string },
|
||||
) {
|
||||
const existing = await prisma.staffInvitation.findFirst({
|
||||
where: { staffId: params.staffId, businessId: params.businessId },
|
||||
});
|
||||
if (!existing) return null;
|
||||
|
||||
return prisma.staffInvitation.update({
|
||||
where: { id: existing.id },
|
||||
data: { token: generateToken(), expiresAt: new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000), acceptedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getInvitationByToken(prisma: PrismaClient, token: string) {
|
||||
return prisma.staffInvitation.findUnique({
|
||||
where: { token },
|
||||
include: {
|
||||
staff: { include: { user: { select: { id: true, name: true, email: true } }, business: { select: { name: true } } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type InvitationCheck =
|
||||
| { valid: true; invitation: NonNullable<Awaited<ReturnType<typeof getInvitationByToken>>> }
|
||||
| { valid: false; reason: StaffInvitationErrorCode };
|
||||
|
||||
export async function validateInvitationToken(prisma: PrismaClient, token: string): Promise<InvitationCheck> {
|
||||
const invitation = await getInvitationByToken(prisma, token);
|
||||
if (!invitation) return { valid: false, reason: "INVALID_TOKEN" };
|
||||
if (invitation.acceptedAt) return { valid: false, reason: "ALREADY_ACCEPTED" };
|
||||
if (invitation.expiresAt < new Date()) return { valid: false, reason: "EXPIRED" };
|
||||
return { valid: true, invitation };
|
||||
}
|
||||
|
||||
export async function acceptInvitation(
|
||||
prisma: PrismaClient,
|
||||
params: { token: string; password: string },
|
||||
): Promise<{ success: boolean; reason?: StaffInvitationErrorCode }> {
|
||||
if (params.password.length < 8) return { success: false, reason: "INVALID_INPUT" };
|
||||
|
||||
const check = await validateInvitationToken(prisma, params.token);
|
||||
if (!check.valid) return { success: false, reason: check.reason };
|
||||
|
||||
const passwordHash = await bcrypt.hash(params.password, 12);
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: check.invitation.staff.userId },
|
||||
data: { passwordHash, emailVerified: new Date() },
|
||||
}),
|
||||
prisma.staffInvitation.update({
|
||||
where: { id: check.invitation.id },
|
||||
data: { acceptedAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -31,7 +31,10 @@ export type StaffServiceErrorCode =
|
||||
| "BREAK_NOT_FOUND"
|
||||
| "TIME_OFF_NOT_FOUND"
|
||||
| "APPOINTMENT_CONFLICTS"
|
||||
| "PERMISSION_DENIED";
|
||||
| "PERMISSION_DENIED"
|
||||
| "EMAIL_ALREADY_IN_USE"
|
||||
| "ROLE_NOT_FOUND"
|
||||
| "LAST_OWNER";
|
||||
|
||||
export class StaffPermissionDeniedError extends Error {
|
||||
constructor(message = "You don't have permission to manage this staff member's schedule.") {
|
||||
@@ -625,3 +628,360 @@ export async function setNotificationPreferences(
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Owner: team management ─────────────────────────────────────────────
|
||||
// Everything below acts on staff *other than* the caller — the owner's own
|
||||
// business-membership must already be verified by the caller (see
|
||||
// src/features/owner/authorization.ts); every function here additionally
|
||||
// re-scopes by businessId itself rather than trusting the caller alone.
|
||||
|
||||
export async function listStaffForBusiness(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; q?: string; includeInactive?: boolean },
|
||||
) {
|
||||
return prisma.staffProfile.findMany({
|
||||
where: {
|
||||
businessId: params.businessId,
|
||||
...(params.includeInactive ? {} : { status: { not: "INACTIVE" } }),
|
||||
...(params.q
|
||||
? {
|
||||
user: {
|
||||
OR: [
|
||||
{ name: { contains: params.q, mode: "insensitive" } },
|
||||
{ email: { contains: params.q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, phone: true, image: true } },
|
||||
role: { select: { id: true, name: true } },
|
||||
invitation: true,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getStaffDetailById(prisma: PrismaClient, params: { staffId: string; businessId: string }) {
|
||||
return prisma.staffProfile.findFirst({
|
||||
where: { id: params.staffId, businessId: params.businessId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, phone: true, image: true } },
|
||||
role: { include: { permissions: true } },
|
||||
services: { include: { service: true } },
|
||||
invitation: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function listRolesForBusiness(prisma: PrismaClient, businessId: string) {
|
||||
return prisma.role.findMany({
|
||||
where: { businessId },
|
||||
include: { permissions: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
/** A role holding MANAGE_STAFF is this app's definition of "an owner" — see docs/phase-6-summary.md, "Roles and permissions". */
|
||||
async function countOtherActiveManagers(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; excludeStaffId: string },
|
||||
) {
|
||||
return prisma.staffProfile.count({
|
||||
where: {
|
||||
businessId: params.businessId,
|
||||
status: "ACTIVE",
|
||||
id: { not: params.excludeStaffId },
|
||||
role: { permissions: { some: { permission: "MANAGE_STAFF" } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function staffCurrentlyManagesStaff(prisma: PrismaClient, staffId: string): Promise<boolean> {
|
||||
const staff = await prisma.staffProfile.findUnique({
|
||||
where: { id: staffId },
|
||||
select: { status: true, role: { select: { permissions: { select: { permission: true } } } } },
|
||||
});
|
||||
return (
|
||||
!!staff &&
|
||||
staff.status === "ACTIVE" &&
|
||||
staff.role.permissions.some((p) => p.permission === "MANAGE_STAFF")
|
||||
);
|
||||
}
|
||||
|
||||
export interface CreateStaffInput {
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
image?: string;
|
||||
bio?: string;
|
||||
roleId: string;
|
||||
serviceIds: string[];
|
||||
isBookableOnline: boolean;
|
||||
}
|
||||
|
||||
export function validateCreateStaffInput(input: CreateStaffInput): ValidationResult<StaffServiceErrorCode> {
|
||||
if (!input.name.trim()) return fail({ code: "INVALID_INPUT", message: "Name is required." });
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
|
||||
return fail({ code: "INVALID_INPUT", message: "Enter a valid email address." });
|
||||
}
|
||||
if (!input.roleId) return fail({ code: "INVALID_INPUT", message: "Choose a role." });
|
||||
return ok();
|
||||
}
|
||||
|
||||
/** Creates the User + StaffProfile + initial service eligibility immediately — the profile is fully usable right away. Working hours default to "off every day" until the owner (or the staff member, once they've accepted) sets them; StaffInvitationService is what tracks acceptance, deliberately kept separate — see that file. */
|
||||
export async function createStaffProfile(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; input: CreateStaffInput },
|
||||
) {
|
||||
const validation = validateCreateStaffInput(params.input);
|
||||
if (!validation.valid) return { success: false as const, validation };
|
||||
|
||||
const [role, existingUser] = await Promise.all([
|
||||
prisma.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } }),
|
||||
prisma.user.findUnique({ where: { email: params.input.email.toLowerCase() } }),
|
||||
]);
|
||||
if (!role) {
|
||||
return { success: false as const, validation: fail({ code: "ROLE_NOT_FOUND", message: "Role not found." }) };
|
||||
}
|
||||
if (existingUser) {
|
||||
return {
|
||||
success: false as const,
|
||||
validation: fail({ code: "EMAIL_ALREADY_IN_USE", message: "That email is already in use." }),
|
||||
};
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: params.input.name.trim(),
|
||||
email: params.input.email.toLowerCase(),
|
||||
phone: params.input.phone || null,
|
||||
image: params.input.image || null,
|
||||
staffProfile: {
|
||||
create: {
|
||||
businessId: params.businessId,
|
||||
roleId: params.input.roleId,
|
||||
bio: params.input.bio?.trim() || null,
|
||||
isBookableOnline: params.input.isBookableOnline,
|
||||
services: { create: params.input.serviceIds.map((serviceId) => ({ serviceId })) },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { staffProfile: true },
|
||||
});
|
||||
|
||||
return { success: true as const, staff: user.staffProfile!, validation: ok() };
|
||||
}
|
||||
|
||||
export interface UpdateStaffAsOwnerInput {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
image?: string;
|
||||
bio?: string;
|
||||
isBookableOnline?: boolean;
|
||||
roleId?: string;
|
||||
}
|
||||
|
||||
export async function updateStaffAsOwner(
|
||||
prisma: PrismaClient,
|
||||
params: { staffId: string; businessId: string; input: UpdateStaffAsOwnerInput },
|
||||
) {
|
||||
const existing = await prisma.staffProfile.findFirst({
|
||||
where: { id: params.staffId, businessId: params.businessId },
|
||||
});
|
||||
if (!existing) {
|
||||
return { success: false as const, validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }) };
|
||||
}
|
||||
|
||||
if (params.input.roleId && params.input.roleId !== existing.roleId) {
|
||||
const role = await prisma.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } });
|
||||
if (!role) {
|
||||
return { success: false as const, validation: fail({ code: "ROLE_NOT_FOUND", message: "Role not found." }) };
|
||||
}
|
||||
const wasManager = await staffCurrentlyManagesStaff(prisma, params.staffId);
|
||||
const stillManagesAfter = !!(await prisma.rolePermission.findFirst({
|
||||
where: { roleId: role.id, permission: "MANAGE_STAFF" },
|
||||
}));
|
||||
if (wasManager && !stillManagesAfter) {
|
||||
const otherManagers = await countOtherActiveManagers(prisma, {
|
||||
businessId: params.businessId,
|
||||
excludeStaffId: params.staffId,
|
||||
});
|
||||
if (otherManagers === 0) {
|
||||
return {
|
||||
success: false as const,
|
||||
validation: fail({
|
||||
code: "LAST_OWNER",
|
||||
message: "This is the only active owner — assign another owner first.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: existing.userId },
|
||||
data: {
|
||||
...(params.input.name !== undefined ? { name: params.input.name } : {}),
|
||||
...(params.input.phone !== undefined ? { phone: params.input.phone || null } : {}),
|
||||
...(params.input.image !== undefined ? { image: params.input.image || null } : {}),
|
||||
},
|
||||
}),
|
||||
prisma.staffProfile.update({
|
||||
where: { id: params.staffId },
|
||||
data: {
|
||||
...(params.input.bio !== undefined ? { bio: params.input.bio || null } : {}),
|
||||
...(params.input.isBookableOnline !== undefined
|
||||
? { isBookableOnline: params.input.isBookableOnline }
|
||||
: {}),
|
||||
...(params.input.roleId !== undefined ? { roleId: params.input.roleId } : {}),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { success: true as const, validation: ok() };
|
||||
}
|
||||
|
||||
/** Future, non-cancelled appointments still assigned to this staff member — shown for acknowledgement before deactivating, never auto-resolved. Deactivating does not touch these rows; it only removes the staff member from future booking/availability. */
|
||||
export async function findStaffDeactivationConflicts(
|
||||
prisma: PrismaClient,
|
||||
params: { staffId: string },
|
||||
) {
|
||||
return prisma.appointment.findMany({
|
||||
where: {
|
||||
staffId: params.staffId,
|
||||
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
|
||||
startAt: { gte: new Date() },
|
||||
},
|
||||
include: { customer: true, services: { include: { service: true } } },
|
||||
orderBy: { startAt: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deactivateStaff(
|
||||
prisma: PrismaClient,
|
||||
params: { staffId: string; businessId: string; acknowledgeConflicts?: boolean },
|
||||
) {
|
||||
const existing = await prisma.staffProfile.findFirst({
|
||||
where: { id: params.staffId, businessId: params.businessId },
|
||||
});
|
||||
if (!existing) {
|
||||
return {
|
||||
success: false as const,
|
||||
validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }),
|
||||
conflicts: [],
|
||||
};
|
||||
}
|
||||
|
||||
const wasManager = await staffCurrentlyManagesStaff(prisma, params.staffId);
|
||||
if (wasManager) {
|
||||
const otherManagers = await countOtherActiveManagers(prisma, {
|
||||
businessId: params.businessId,
|
||||
excludeStaffId: params.staffId,
|
||||
});
|
||||
if (otherManagers === 0) {
|
||||
return {
|
||||
success: false as const,
|
||||
validation: fail({
|
||||
code: "LAST_OWNER",
|
||||
message: "This is the only active owner — assign another owner first.",
|
||||
}),
|
||||
conflicts: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!params.acknowledgeConflicts) {
|
||||
const conflicts = await findStaffDeactivationConflicts(prisma, { staffId: params.staffId });
|
||||
if (conflicts.length > 0) {
|
||||
return {
|
||||
success: false as const,
|
||||
conflicts,
|
||||
validation: fail({
|
||||
code: "APPOINTMENT_CONFLICTS",
|
||||
message: "This staff member still has upcoming appointments.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.staffProfile.update({ where: { id: params.staffId }, data: { status: "INACTIVE" } });
|
||||
return { success: true as const, conflicts: [], validation: ok() };
|
||||
}
|
||||
|
||||
export async function reactivateStaff(
|
||||
prisma: PrismaClient,
|
||||
params: { staffId: string; businessId: string },
|
||||
): Promise<{ success: boolean }> {
|
||||
const result = await prisma.staffProfile.updateMany({
|
||||
where: { id: params.staffId, businessId: params.businessId },
|
||||
data: { status: "ACTIVE" },
|
||||
});
|
||||
return { success: result.count > 0 };
|
||||
}
|
||||
|
||||
/** Future, non-cancelled appointments this staff member has that book a service being *removed* from their eligible list — the only ones that matter, since keeping or adding eligibility can never orphan an existing booking. */
|
||||
export async function findServiceEligibilityConflicts(
|
||||
prisma: PrismaClient,
|
||||
params: { staffId: string; removedServiceIds: string[] },
|
||||
) {
|
||||
if (params.removedServiceIds.length === 0) return [];
|
||||
return prisma.appointment.findMany({
|
||||
where: {
|
||||
staffId: params.staffId,
|
||||
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
|
||||
startAt: { gte: new Date() },
|
||||
services: { some: { serviceId: { in: params.removedServiceIds } } },
|
||||
},
|
||||
include: { customer: true, services: { include: { service: true } } },
|
||||
orderBy: { startAt: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function setStaffServiceEligibility(
|
||||
prisma: PrismaClient,
|
||||
params: { staffId: string; businessId: string; serviceIds: string[]; acknowledgeConflicts?: boolean },
|
||||
) {
|
||||
const existing = await prisma.staffProfile.findFirst({
|
||||
where: { id: params.staffId, businessId: params.businessId },
|
||||
include: { services: true },
|
||||
});
|
||||
if (!existing) {
|
||||
return {
|
||||
success: false as const,
|
||||
validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }),
|
||||
conflicts: [],
|
||||
};
|
||||
}
|
||||
|
||||
const currentServiceIds = existing.services.map((s) => s.serviceId);
|
||||
const removedServiceIds = currentServiceIds.filter((id) => !params.serviceIds.includes(id));
|
||||
|
||||
if (!params.acknowledgeConflicts) {
|
||||
const conflicts = await findServiceEligibilityConflicts(prisma, {
|
||||
staffId: params.staffId,
|
||||
removedServiceIds,
|
||||
});
|
||||
if (conflicts.length > 0) {
|
||||
return {
|
||||
success: false as const,
|
||||
conflicts,
|
||||
validation: fail({
|
||||
code: "APPOINTMENT_CONFLICTS",
|
||||
message: "This staff member has upcoming appointments for a service you're removing.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.staffService.deleteMany({ where: { staffId: params.staffId } }),
|
||||
prisma.staffService.createMany({
|
||||
data: params.serviceIds.map((serviceId) => ({ staffId: params.staffId, serviceId })),
|
||||
}),
|
||||
]);
|
||||
|
||||
return { success: true as const, conflicts: [], validation: ok() };
|
||||
}
|
||||
Reference in new issue
Block a user