The owner page Where: Manage → Business → Holidays, at /owner/settings/holidays. It needs the same permission as Business Details. Adding: you enter a name, a start date and an optional end date. Leaving the end empty gives a single day. Upcoming and past: upcoming holidays are listed by date, with a trash icon to remove each one. Past ones sit in a collapsed “Past” list. Existing bookings: if appointments are already booked on those dates, you see them and confirm with “Add Anyway”, the same pattern as Opening Hours. Nothing is cancelled automatically. What it does to booking Customer calendar: holiday dates are greyed out, for every staff member and every service. Time slots: no slots are offered on those dates. Direct bookings: staff and owner bookings and reschedules onto a holiday are refused, with a message naming the holiday. This is deliberate, but if you want owners to be able to override it, that’s a small change.
This commit is contained in:
13 files changed
+914
-3
No files matched your search
@@ -0,0 +1,16 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `BusinessHoliday` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`businessId` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`startDate` DATE NOT NULL,
|
||||||
|
`endDate` DATE NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `BusinessHoliday_businessId_startDate_endDate_idx`(`businessId`, `startDate`, `endDate`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `BusinessHoliday` ADD CONSTRAINT `BusinessHoliday_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -344,6 +344,7 @@ model Business {
|
|||||||
|
|
||||||
locations BusinessLocation[]
|
locations BusinessLocation[]
|
||||||
hours BusinessHours[]
|
hours BusinessHours[]
|
||||||
|
holidays BusinessHoliday[]
|
||||||
settings BusinessSettings?
|
settings BusinessSettings?
|
||||||
bookingRule BookingRule?
|
bookingRule BookingRule?
|
||||||
serviceCategories ServiceCategory[]
|
serviceCategories ServiceCategory[]
|
||||||
@@ -436,6 +437,26 @@ model BusinessSettings {
|
|||||||
/// Scheduling policy — see docs/implementation-plan.md §6/§16. Every field
|
/// Scheduling policy — see docs/implementation-plan.md §6/§16. Every field
|
||||||
/// has a sane default so the row can be created once at business setup and
|
/// has a sane default so the row can be created once at business setup and
|
||||||
/// tuned later without a migration.
|
/// tuned later without a migration.
|
||||||
|
/// A date range the whole business is closed — bank holidays, annual leave,
|
||||||
|
/// a refit — on top of the weekly BusinessHours. Both dates are inclusive
|
||||||
|
/// calendar dates in the business's own timezone (no time component), the
|
||||||
|
/// same convention as TimeOff. Availability treats every date in range as
|
||||||
|
/// closed for every staff member; see availability.service.ts.
|
||||||
|
model BusinessHoliday {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
businessId String
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
name String
|
||||||
|
startDate DateTime @db.Date
|
||||||
|
endDate DateTime @db.Date
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([businessId, startDate, endDate])
|
||||||
|
}
|
||||||
|
|
||||||
model BookingRule {
|
model BookingRule {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Calendar,
|
Calendar,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
|
CalendarOff,
|
||||||
CalendarX2,
|
CalendarX2,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
@@ -58,6 +59,7 @@ function getManageSections(isSuper: boolean) {
|
|||||||
links: [
|
links: [
|
||||||
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
|
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
|
||||||
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
|
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
|
||||||
|
{ href: "/owner/settings/holidays", label: "Holidays", icon: CalendarOff },
|
||||||
{ href: "/owner/settings/gallery", label: "Gallery", icon: Images },
|
{ href: "/owner/settings/gallery", label: "Gallery", icon: Images },
|
||||||
...(isSuper
|
...(isSuper
|
||||||
? [{ href: "/owner/settings/app", label: "App Icon & Home Screen", icon: Smartphone }]
|
? [{ href: "/owner/settings/app", label: "App Icon & Home Screen", icon: Smartphone }]
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { hasPermission } from "@/lib/auth/permissions";
|
||||||
|
import { Permission } from "@/generated/prisma/enums";
|
||||||
|
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||||
|
import { todayIn } from "@/lib/time/business-time";
|
||||||
|
import { listBusinessHolidays } from "@/services/business/holiday.service";
|
||||||
|
import { HolidaysManager } from "@/features/owner/components/holidays-manager";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Holidays" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function OwnerHolidaysPage() {
|
||||||
|
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 context = await getActingBusinessContext();
|
||||||
|
if (!context) redirect("/staff");
|
||||||
|
|
||||||
|
const business = await prisma.business.findUnique({
|
||||||
|
where: { id: context.businessId },
|
||||||
|
select: { timezone: true, locale: true },
|
||||||
|
});
|
||||||
|
if (!business) redirect("/staff");
|
||||||
|
|
||||||
|
const holidays = await listBusinessHolidays(prisma, context.businessId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsPageShell title="Holidays" mainClassName="lg:max-w-2xl">
|
||||||
|
<HolidaysManager
|
||||||
|
holidays={holidays}
|
||||||
|
today={todayIn(business.timezone)}
|
||||||
|
locale={business.locale}
|
||||||
|
/>
|
||||||
|
</SettingsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ const FRIENDLY_MESSAGES: Record<BookingValidationErrorCode, string> = {
|
|||||||
SERVICES_SPAN_MULTIPLE_BUSINESSES: "One or more selected services aren't available here.",
|
SERVICES_SPAN_MULTIPLE_BUSINESSES: "One or more selected services aren't available here.",
|
||||||
STAFF_CANNOT_PERFORM_SERVICE: "This barber doesn't offer one of the selected services.",
|
STAFF_CANNOT_PERFORM_SERVICE: "This barber doesn't offer one of the selected services.",
|
||||||
BUSINESS_CLOSED: "The business is closed at this time — please choose another slot.",
|
BUSINESS_CLOSED: "The business is closed at this time — please choose another slot.",
|
||||||
|
BUSINESS_HOLIDAY: "The business is closed on that date — please choose another day.",
|
||||||
STAFF_NOT_WORKING: "This barber isn't working at that time — please choose another slot or barber.",
|
STAFF_NOT_WORKING: "This barber isn't working at that time — please choose another slot or barber.",
|
||||||
STAFF_ON_BREAK: "This barber is on a break at that time — please choose another slot.",
|
STAFF_ON_BREAK: "This barber is on a break at that time — please choose another slot.",
|
||||||
STAFF_ON_LEAVE: "This barber is away on that date — please choose another barber or date.",
|
STAFF_ON_LEAVE: "This barber is away on that date — please choose another barber or date.",
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"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 { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||||
|
import {
|
||||||
|
createBusinessHoliday,
|
||||||
|
deleteBusinessHoliday,
|
||||||
|
findHolidayConflicts,
|
||||||
|
validateHolidayInput,
|
||||||
|
} from "@/services/business/holiday.service";
|
||||||
|
import type { HolidayInput } from "@/services/business/holiday.service";
|
||||||
|
|
||||||
|
export interface HolidayActionResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
/** Appointments already booked inside the range — shown for review, never cancelled automatically. */
|
||||||
|
conflicts?: {
|
||||||
|
id: string;
|
||||||
|
customerName: string;
|
||||||
|
serviceNames: string;
|
||||||
|
startAt: string;
|
||||||
|
staffName: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Holidays change when the whole business can be booked, so they share Business Details' permission. */
|
||||||
|
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 context = await getActingBusinessContext();
|
||||||
|
if (!context) return { staff: null, error: "You must be signed in as staff." } as const;
|
||||||
|
|
||||||
|
return {
|
||||||
|
staff: { businessId: context.businessId, userId: context.userId },
|
||||||
|
error: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addHolidayAction(
|
||||||
|
holiday: HolidayInput,
|
||||||
|
acknowledgeConflicts?: boolean,
|
||||||
|
): Promise<HolidayActionResult> {
|
||||||
|
const { staff, error } = await requireBusinessManager();
|
||||||
|
if (!staff) return { success: false, error: error ?? "Not authorized." };
|
||||||
|
|
||||||
|
const validation = validateHolidayInput(holiday);
|
||||||
|
if (!validation.valid) return { success: false, error: validation.issues[0]?.message };
|
||||||
|
|
||||||
|
if (!acknowledgeConflicts) {
|
||||||
|
const business = await prisma.business.findUnique({
|
||||||
|
where: { id: staff.businessId },
|
||||||
|
select: { timezone: true },
|
||||||
|
});
|
||||||
|
if (!business) return { success: false, error: "Business not found." };
|
||||||
|
|
||||||
|
const conflicts = await findHolidayConflicts(prisma, {
|
||||||
|
businessId: staff.businessId,
|
||||||
|
businessTimezone: business.timezone,
|
||||||
|
startDate: holiday.startDate,
|
||||||
|
endDate: holiday.endDate,
|
||||||
|
});
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Some appointments are already booked on these dates.",
|
||||||
|
conflicts: 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",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createBusinessHoliday(prisma, { businessId: staff.businessId, holiday });
|
||||||
|
if (!result.success) return { success: false, error: result.validation.issues[0]?.message };
|
||||||
|
|
||||||
|
await writeAuditLog(prisma, {
|
||||||
|
actorUserId: staff.userId,
|
||||||
|
action: "BUSINESS_HOLIDAY_CREATED",
|
||||||
|
entityType: "BusinessHoliday",
|
||||||
|
entityId: result.id,
|
||||||
|
metadata: { name: holiday.name.trim(), startDate: holiday.startDate, endDate: holiday.endDate },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHolidayAction(holidayId: string): Promise<HolidayActionResult> {
|
||||||
|
const { staff, error } = await requireBusinessManager();
|
||||||
|
if (!staff) return { success: false, error: error ?? "Not authorized." };
|
||||||
|
|
||||||
|
const deleted = await deleteBusinessHoliday(prisma, { businessId: staff.businessId, holidayId });
|
||||||
|
if (!deleted) return { success: false, error: "That holiday no longer exists." };
|
||||||
|
|
||||||
|
await writeAuditLog(prisma, {
|
||||||
|
actorUserId: staff.userId,
|
||||||
|
action: "BUSINESS_HOLIDAY_DELETED",
|
||||||
|
entityType: "BusinessHoliday",
|
||||||
|
entityId: holidayId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Trash2 } 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 { Input } from "@/components/ui/input";
|
||||||
|
import { addHolidayAction, deleteHolidayAction } from "../actions/holidays";
|
||||||
|
import type { HolidayActionResult } from "../actions/holidays";
|
||||||
|
|
||||||
|
export interface HolidayRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** Inclusive calendar dates, "YYYY-MM-DD" in the business's own timezone. */
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: string, locale: string): string {
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
weekday: "short",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "UTC",
|
||||||
|
}).format(new Date(`${date}T00:00:00Z`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRange(holiday: HolidayRow, locale: string): string {
|
||||||
|
return holiday.startDate === holiday.endDate
|
||||||
|
? formatDate(holiday.startDate, locale)
|
||||||
|
: `${formatDate(holiday.startDate, locale)} – ${formatDate(holiday.endDate, locale)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HolidaysManager({
|
||||||
|
holidays,
|
||||||
|
today,
|
||||||
|
locale,
|
||||||
|
}: {
|
||||||
|
holidays: HolidayRow[];
|
||||||
|
today: string;
|
||||||
|
locale: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [startDate, setStartDate] = useState("");
|
||||||
|
const [endDate, setEndDate] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [conflicts, setConflicts] = useState<NonNullable<HolidayActionResult["conflicts"]>>([]);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// ISO dates compare correctly as plain strings.
|
||||||
|
const upcoming = holidays.filter((h) => h.endDate >= today);
|
||||||
|
const past = holidays.filter((h) => h.endDate < today).reverse();
|
||||||
|
|
||||||
|
const submit = (acknowledgeConflicts: boolean) => {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await addHolidayAction(
|
||||||
|
{ name, startDate, endDate: endDate || startDate },
|
||||||
|
acknowledgeConflicts,
|
||||||
|
);
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error ?? "Couldn't add the holiday.");
|
||||||
|
setConflicts(result.conflicts ?? []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setName("");
|
||||||
|
setStartDate("");
|
||||||
|
setEndDate("");
|
||||||
|
setConflicts([]);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (holidayId: string) => {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await deleteHolidayAction(holidayId);
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error ?? "Couldn't remove the holiday.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p role="alert" className="text-danger text-sm font-medium">
|
||||||
|
{conflicts.length} {conflicts.length > 1 ? "appointments are" : "appointment is"} already
|
||||||
|
booked on these dates. They won't be cancelled automatically — review them, then
|
||||||
|
confirm if you still want to close the business.
|
||||||
|
</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">
|
||||||
|
{new Intl.DateTimeFormat(locale, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(new Date(c.startAt))}{" "}
|
||||||
|
· {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)}
|
||||||
|
>
|
||||||
|
Add Anyway
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">
|
||||||
|
Add a holiday
|
||||||
|
</h3>
|
||||||
|
<div className="border-line space-y-4 rounded-md border bg-white p-6">
|
||||||
|
<p className="text-muted text-xs leading-relaxed">
|
||||||
|
The whole business is closed on these dates: nobody can be booked, for any service or
|
||||||
|
staff member. Existing appointments are never cancelled automatically.
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
placeholder="Christmas Day"
|
||||||
|
maxLength={80}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="From"
|
||||||
|
type="date"
|
||||||
|
min={today}
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStartDate(e.target.value);
|
||||||
|
if (endDate && e.target.value > endDate) setEndDate("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="To (optional)"
|
||||||
|
type="date"
|
||||||
|
min={startDate || today}
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
hint="Leave empty for a single day."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-danger text-sm font-medium">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
isLoading={isPending}
|
||||||
|
disabled={!name.trim() || !startDate}
|
||||||
|
onClick={() => submit(false)}
|
||||||
|
>
|
||||||
|
Add Holiday
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">
|
||||||
|
Upcoming
|
||||||
|
</h3>
|
||||||
|
{upcoming.length === 0 ? (
|
||||||
|
<p className="text-muted border-line rounded-md border bg-white p-6 text-sm">
|
||||||
|
No holidays set. The business follows its regular opening hours every day.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-line border-line divide-y rounded-md border bg-white">
|
||||||
|
{upcoming.map((holiday) => (
|
||||||
|
<HolidayItem
|
||||||
|
key={holiday.id}
|
||||||
|
holiday={holiday}
|
||||||
|
locale={locale}
|
||||||
|
disabled={isPending}
|
||||||
|
onRemove={remove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{past.length > 0 && (
|
||||||
|
<details className="space-y-3">
|
||||||
|
<summary className="text-muted ml-1 cursor-pointer text-[10px] font-bold tracking-widest uppercase">
|
||||||
|
Past ({past.length})
|
||||||
|
</summary>
|
||||||
|
<ul className="divide-line border-line mt-3 divide-y rounded-md border bg-white opacity-70">
|
||||||
|
{past.map((holiday) => (
|
||||||
|
<HolidayItem
|
||||||
|
key={holiday.id}
|
||||||
|
holiday={holiday}
|
||||||
|
locale={locale}
|
||||||
|
disabled={isPending}
|
||||||
|
onRemove={remove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HolidayItem({
|
||||||
|
holiday,
|
||||||
|
locale,
|
||||||
|
disabled,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
holiday: HolidayRow;
|
||||||
|
locale: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li className="flex items-center justify-between gap-4 px-5 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-bold">{holiday.name}</p>
|
||||||
|
<p className="text-muted text-xs">{formatRange(holiday, locale)}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Remove ${holiday.name}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onRemove(holiday.id)}
|
||||||
|
className="text-muted hover:text-danger flex h-9 w-9 shrink-0 items-center justify-center rounded-full disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
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 {
|
||||||
|
getAvailableDates,
|
||||||
|
getAvailableTimeSlots,
|
||||||
|
validateBookingSlot,
|
||||||
|
} from "@/services/scheduling/availability.service";
|
||||||
|
import {
|
||||||
|
cleanupTestBusiness,
|
||||||
|
createTestAppointment,
|
||||||
|
createTestBusiness,
|
||||||
|
createTestCustomer,
|
||||||
|
createTestStaff,
|
||||||
|
prisma,
|
||||||
|
} from "@/services/scheduling/__tests__/fixtures";
|
||||||
|
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||||
|
import {
|
||||||
|
createBusinessHoliday,
|
||||||
|
deleteBusinessHoliday,
|
||||||
|
findHolidayConflicts,
|
||||||
|
getClosedDates,
|
||||||
|
listBusinessHolidays,
|
||||||
|
} from "../holiday.service";
|
||||||
|
|
||||||
|
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("business holidays (integration)", () => {
|
||||||
|
let business: TestBusiness;
|
||||||
|
let monday: CalendarDate;
|
||||||
|
let tuesday: CalendarDate;
|
||||||
|
let wednesday: CalendarDate;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
business = await createTestBusiness();
|
||||||
|
monday = nextWeekday(business.timezone, Weekday.MONDAY);
|
||||||
|
tuesday = addDays(monday, 1);
|
||||||
|
wednesday = addDays(monday, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await cleanupTestBusiness(business);
|
||||||
|
});
|
||||||
|
|
||||||
|
const addHoliday = (name: string, startDate: CalendarDate, endDate: CalendarDate) =>
|
||||||
|
createBusinessHoliday(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
holiday: { name, startDate, endDate },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores and lists holidays as inclusive calendar dates", async () => {
|
||||||
|
await addHoliday("Refit", monday, tuesday);
|
||||||
|
|
||||||
|
expect(await listBusinessHolidays(prisma, business.businessId)).toEqual([
|
||||||
|
expect.objectContaining({ name: "Refit", startDate: monday, endDate: tuesday }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes every date in a range, but not the days around it", async () => {
|
||||||
|
await createTestStaff(business);
|
||||||
|
await addHoliday("Refit", monday, tuesday);
|
||||||
|
|
||||||
|
const { dates } = await getAvailableDates(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
serviceIds: [business.haircutServiceId],
|
||||||
|
fromDate: monday,
|
||||||
|
toDate: wednesday,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dates).not.toContain(monday);
|
||||||
|
expect(dates).not.toContain(tuesday);
|
||||||
|
expect(dates).toContain(wednesday);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no time slots on a holiday", async () => {
|
||||||
|
await createTestStaff(business);
|
||||||
|
await addHoliday("Bank holiday", monday, monday);
|
||||||
|
|
||||||
|
const { slots, validation } = await getAvailableTimeSlots(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
serviceIds: [business.haircutServiceId],
|
||||||
|
date: monday,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validation.valid).toBe(true);
|
||||||
|
expect(slots).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to book a holiday directly, naming it", async () => {
|
||||||
|
const { staffId } = await createTestStaff(business);
|
||||||
|
await addHoliday("Bank holiday", monday, monday);
|
||||||
|
|
||||||
|
const result = await validateBookingSlot(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
serviceIds: [business.haircutServiceId],
|
||||||
|
staffId,
|
||||||
|
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.validation.valid).toBe(false);
|
||||||
|
expect(result.validation.issues.map((i) => i.code)).toContain("BUSINESS_HOLIDAY");
|
||||||
|
expect(result.validation.issues.find((i) => i.code === "BUSINESS_HOLIDAY")?.message).toContain(
|
||||||
|
"Bank holiday",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reopens the dates once the holiday is removed", async () => {
|
||||||
|
await createTestStaff(business);
|
||||||
|
const created = await addHoliday("Bank holiday", monday, monday);
|
||||||
|
if (!created.success) throw new Error("setup failed");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await deleteBusinessHoliday(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
holidayId: created.id,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const { dates } = await getAvailableDates(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
serviceIds: [business.haircutServiceId],
|
||||||
|
fromDate: monday,
|
||||||
|
toDate: monday,
|
||||||
|
});
|
||||||
|
expect(dates).toContain(monday);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never deletes another business's holiday", async () => {
|
||||||
|
const created = await addHoliday("Bank holiday", monday, monday);
|
||||||
|
if (!created.success) throw new Error("setup failed");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await deleteBusinessHoliday(prisma, { businessId: "someone-else", holidayId: created.id }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(await listBusinessHolidays(prisma, business.businessId)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only reports closed dates inside the window asked for", async () => {
|
||||||
|
await addHoliday("Long break", monday, addDays(monday, 10));
|
||||||
|
|
||||||
|
const closed = await getClosedDates(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
fromDate: tuesday,
|
||||||
|
toDate: wednesday,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([...closed.keys()]).toEqual([tuesday, wednesday]);
|
||||||
|
expect(closed.get(tuesday)).toBe("Long break");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports appointments already booked in the range, without cancelling them", async () => {
|
||||||
|
const { staffId } = await createTestStaff(business);
|
||||||
|
const { customerId } = await createTestCustomer(business);
|
||||||
|
const inRange = await createTestAppointment(business, {
|
||||||
|
customerId,
|
||||||
|
staffId,
|
||||||
|
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||||
|
});
|
||||||
|
await createTestAppointment(business, {
|
||||||
|
customerId,
|
||||||
|
staffId,
|
||||||
|
startAt: wallClockToInstant(addDays(monday, 5), 10 * 60, business.timezone),
|
||||||
|
});
|
||||||
|
|
||||||
|
const conflicts = await findHolidayConflicts(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
businessTimezone: business.timezone,
|
||||||
|
startDate: monday,
|
||||||
|
endDate: tuesday,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(conflicts.map((c) => c.id)).toEqual([inRange.appointmentId]);
|
||||||
|
const stored = await prisma.appointment.findUniqueOrThrow({
|
||||||
|
where: { id: inRange.appointmentId },
|
||||||
|
});
|
||||||
|
expect(stored.status).not.toBe("CANCELLED");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
HOLIDAY_MAX_SPAN_DAYS,
|
||||||
|
HOLIDAY_NAME_MAX_LENGTH,
|
||||||
|
validateHolidayInput,
|
||||||
|
} from "../holiday.service";
|
||||||
|
|
||||||
|
const base = { name: "Christmas", startDate: "2026-12-25", endDate: "2026-12-26" };
|
||||||
|
|
||||||
|
describe("validateHolidayInput", () => {
|
||||||
|
it("accepts a single day and a multi-day range", () => {
|
||||||
|
expect(validateHolidayInput({ ...base, endDate: "2026-12-25" }).valid).toBe(true);
|
||||||
|
expect(validateHolidayInput(base).valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a name", () => {
|
||||||
|
const result = validateHolidayInput({ ...base, name: " " });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an over-long name", () => {
|
||||||
|
expect(
|
||||||
|
validateHolidayInput({ ...base, name: "x".repeat(HOLIDAY_NAME_MAX_LENGTH + 1) }).valid,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects dates that aren't real calendar dates", () => {
|
||||||
|
expect(validateHolidayInput({ ...base, startDate: "2026-02-30" }).valid).toBe(false);
|
||||||
|
expect(validateHolidayInput({ ...base, endDate: "25/12/2026" }).valid).toBe(false);
|
||||||
|
expect(validateHolidayInput({ ...base, startDate: "" }).valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an end date before the start date", () => {
|
||||||
|
expect(
|
||||||
|
validateHolidayInput({ ...base, startDate: "2026-12-26", endDate: "2026-12-25" }).valid,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps how long a holiday can last, returning an error rather than throwing", () => {
|
||||||
|
expect(
|
||||||
|
validateHolidayInput({ ...base, startDate: "2026-01-01", endDate: "2026-12-31" }).valid,
|
||||||
|
).toBe(true);
|
||||||
|
const tooLong = validateHolidayInput({
|
||||||
|
...base,
|
||||||
|
startDate: "2026-01-01",
|
||||||
|
endDate: "2028-01-01",
|
||||||
|
});
|
||||||
|
expect(tooLong.valid).toBe(false);
|
||||||
|
expect(HOLIDAY_MAX_SPAN_DAYS).toBe(366);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import type { PrismaClient } from "@/generated/prisma/client";
|
||||||
|
import {
|
||||||
|
calendarDateToUtcMidnight,
|
||||||
|
compareCalendarDates,
|
||||||
|
eachCalendarDate,
|
||||||
|
endOfBusinessDay,
|
||||||
|
startOfBusinessDay,
|
||||||
|
} from "@/lib/time/business-time";
|
||||||
|
import type { CalendarDate } from "@/lib/time/business-time";
|
||||||
|
import { fail, ok } from "@/lib/validation-result";
|
||||||
|
import type { ValidationResult } from "@/lib/validation-result";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Business-wide closures: bank holidays, annual leave, a refit. Each is an
|
||||||
|
* inclusive range of calendar dates in the business's own timezone during
|
||||||
|
* which nobody can be booked, on top of the weekly opening hours (see
|
||||||
|
* BusinessHoliday in schema.prisma, and availability.service.ts for where
|
||||||
|
* the booking engine honours them). Creating one never cancels anything —
|
||||||
|
* it reports the appointments already in range so the owner can deal with
|
||||||
|
* them, same as the opening-hours conflict pattern.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type HolidayServiceErrorCode = "INVALID_INPUT";
|
||||||
|
|
||||||
|
export const HOLIDAY_NAME_MAX_LENGTH = 80;
|
||||||
|
/** A guard against a typo'd end date closing the business for years. */
|
||||||
|
export const HOLIDAY_MAX_SPAN_DAYS = 366;
|
||||||
|
|
||||||
|
export interface HolidayInput {
|
||||||
|
name: string;
|
||||||
|
startDate: CalendarDate;
|
||||||
|
endDate: CalendarDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
function isRealCalendarDate(value: string): boolean {
|
||||||
|
if (!CALENDAR_DATE.test(value)) return false;
|
||||||
|
const parsed = new Date(`${value}T00:00:00Z`);
|
||||||
|
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateHolidayInput(
|
||||||
|
input: HolidayInput,
|
||||||
|
): ValidationResult<HolidayServiceErrorCode> {
|
||||||
|
if (!input.name.trim())
|
||||||
|
return fail({ code: "INVALID_INPUT", message: "Give the holiday a name." });
|
||||||
|
if (input.name.trim().length > HOLIDAY_NAME_MAX_LENGTH) {
|
||||||
|
return fail({
|
||||||
|
code: "INVALID_INPUT",
|
||||||
|
message: `Keep the name under ${HOLIDAY_NAME_MAX_LENGTH} characters.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!isRealCalendarDate(input.startDate) || !isRealCalendarDate(input.endDate)) {
|
||||||
|
return fail({ code: "INVALID_INPUT", message: "Choose a valid start and end date." });
|
||||||
|
}
|
||||||
|
if (compareCalendarDates(input.endDate, input.startDate) < 0) {
|
||||||
|
return fail({ code: "INVALID_INPUT", message: "The end date can't be before the start date." });
|
||||||
|
}
|
||||||
|
const days =
|
||||||
|
(calendarDateToUtcMidnight(input.endDate).getTime() -
|
||||||
|
calendarDateToUtcMidnight(input.startDate).getTime()) /
|
||||||
|
86_400_000 +
|
||||||
|
1;
|
||||||
|
if (days > HOLIDAY_MAX_SPAN_DAYS) {
|
||||||
|
return fail({ code: "INVALID_INPUT", message: "A holiday can last at most a year." });
|
||||||
|
}
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stored DATE column comes back as UTC midnight — its calendar date is just the ISO date. */
|
||||||
|
function toCalendarDate(stored: Date): CalendarDate {
|
||||||
|
return stored.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listBusinessHolidays(prisma: PrismaClient, businessId: string) {
|
||||||
|
const rows = await prisma.businessHoliday.findMany({
|
||||||
|
where: { businessId },
|
||||||
|
orderBy: [{ startDate: "asc" }, { name: "asc" }],
|
||||||
|
});
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
startDate: toCalendarDate(row.startDate),
|
||||||
|
endDate: toCalendarDate(row.endDate),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BusinessHolidayRow = Awaited<ReturnType<typeof listBusinessHolidays>>[number];
|
||||||
|
|
||||||
|
/** Future, still-active appointments inside the range — reported, never cancelled. */
|
||||||
|
export async function findHolidayConflicts(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
params: {
|
||||||
|
businessId: string;
|
||||||
|
businessTimezone: string;
|
||||||
|
startDate: CalendarDate;
|
||||||
|
endDate: CalendarDate;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return prisma.appointment.findMany({
|
||||||
|
where: {
|
||||||
|
businessId: params.businessId,
|
||||||
|
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
|
||||||
|
startAt: {
|
||||||
|
gte: new Date(),
|
||||||
|
lt: endOfBusinessDay(params.endDate, params.businessTimezone),
|
||||||
|
},
|
||||||
|
endAt: { gt: startOfBusinessDay(params.startDate, params.businessTimezone) },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
services: { include: { service: true } },
|
||||||
|
customer: true,
|
||||||
|
staff: { include: { user: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
orderBy: { startAt: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBusinessHoliday(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
params: { businessId: string; holiday: HolidayInput },
|
||||||
|
): Promise<
|
||||||
|
| { success: true; id: string }
|
||||||
|
| { success: false; validation: ValidationResult<HolidayServiceErrorCode> }
|
||||||
|
> {
|
||||||
|
const validation = validateHolidayInput(params.holiday);
|
||||||
|
if (!validation.valid) return { success: false, validation };
|
||||||
|
|
||||||
|
const created = await prisma.businessHoliday.create({
|
||||||
|
data: {
|
||||||
|
businessId: params.businessId,
|
||||||
|
name: params.holiday.name.trim(),
|
||||||
|
startDate: calendarDateToUtcMidnight(params.holiday.startDate),
|
||||||
|
endDate: calendarDateToUtcMidnight(params.holiday.endDate),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { success: true, id: created.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scoped to the business so one business can never delete another's holiday by id. */
|
||||||
|
export async function deleteBusinessHoliday(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
params: { businessId: string; holidayId: string },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { count } = await prisma.businessHoliday.deleteMany({
|
||||||
|
where: { id: params.holidayId, businessId: params.businessId },
|
||||||
|
});
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every date in [fromDate, toDate] covered by a holiday — what the booking engine treats as closed. */
|
||||||
|
export async function getClosedDates(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
params: { businessId: string; fromDate: CalendarDate; toDate: CalendarDate },
|
||||||
|
): Promise<Map<CalendarDate, string>> {
|
||||||
|
const rows = await prisma.businessHoliday.findMany({
|
||||||
|
where: {
|
||||||
|
businessId: params.businessId,
|
||||||
|
startDate: { lte: calendarDateToUtcMidnight(params.toDate) },
|
||||||
|
endDate: { gte: calendarDateToUtcMidnight(params.fromDate) },
|
||||||
|
},
|
||||||
|
orderBy: { startDate: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const closed = new Map<CalendarDate, string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const rowStart = toCalendarDate(row.startDate);
|
||||||
|
const rowEnd = toCalendarDate(row.endDate);
|
||||||
|
// Clamp to the window asked for, so a long holiday never walks more days than the caller needs.
|
||||||
|
const start = compareCalendarDates(rowStart, params.fromDate) < 0 ? params.fromDate : rowStart;
|
||||||
|
const end = compareCalendarDates(rowEnd, params.toDate) > 0 ? params.toDate : rowEnd;
|
||||||
|
for (const date of eachCalendarDate(start, end)) {
|
||||||
|
if (!closed.has(date)) closed.set(date, row.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return closed;
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
computeEffectiveBuffer,
|
computeEffectiveBuffer,
|
||||||
} from "./business-rules.service";
|
} from "./business-rules.service";
|
||||||
import type { BookingRuleConfig } from "./business-rules.service";
|
import type { BookingRuleConfig } from "./business-rules.service";
|
||||||
|
import { getClosedDates } from "@/services/business/holiday.service";
|
||||||
import { computeFreeWindows, generateSlotStarts } from "./slot-calculator";
|
import { computeFreeWindows, generateSlotStarts } from "./slot-calculator";
|
||||||
import {
|
import {
|
||||||
businessOpenByWeekdayFrom,
|
businessOpenByWeekdayFrom,
|
||||||
@@ -233,6 +234,11 @@ export async function getAvailableDates(
|
|||||||
|
|
||||||
const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } });
|
const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } });
|
||||||
const businessOpenByWeekday = businessOpenByWeekdayFrom(businessHours);
|
const businessOpenByWeekday = businessOpenByWeekdayFrom(businessHours);
|
||||||
|
const closedDates = await getClosedDates(prisma, {
|
||||||
|
businessId: context.business.id,
|
||||||
|
fromDate: params.fromDate,
|
||||||
|
toDate: params.toDate,
|
||||||
|
});
|
||||||
|
|
||||||
const today = todayIn(context.business.timezone);
|
const today = todayIn(context.business.timezone);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -245,6 +251,7 @@ export async function getAvailableDates(
|
|||||||
toDate: params.toDate,
|
toDate: params.toDate,
|
||||||
businessTimezone: context.business.timezone,
|
businessTimezone: context.business.timezone,
|
||||||
businessOpenByWeekday,
|
businessOpenByWeekday,
|
||||||
|
closedDates,
|
||||||
effectiveBuffer: context.effectiveBuffer,
|
effectiveBuffer: context.effectiveBuffer,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -330,6 +337,11 @@ export async function getAvailableTimeSlots(
|
|||||||
|
|
||||||
const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } });
|
const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } });
|
||||||
const businessOpenByWeekday = businessOpenByWeekdayFrom(businessHours);
|
const businessOpenByWeekday = businessOpenByWeekdayFrom(businessHours);
|
||||||
|
const closedDates = await getClosedDates(prisma, {
|
||||||
|
businessId: context.business.id,
|
||||||
|
fromDate: params.date,
|
||||||
|
toDate: params.date,
|
||||||
|
});
|
||||||
|
|
||||||
const businessCount = (
|
const businessCount = (
|
||||||
await countAppointmentsPerDate(prisma, {
|
await countAppointmentsPerDate(prisma, {
|
||||||
@@ -364,6 +376,7 @@ export async function getAvailableTimeSlots(
|
|||||||
toDate: params.date,
|
toDate: params.date,
|
||||||
businessTimezone: context.business.timezone,
|
businessTimezone: context.business.timezone,
|
||||||
businessOpenByWeekday,
|
businessOpenByWeekday,
|
||||||
|
closedDates,
|
||||||
effectiveBuffer: context.effectiveBuffer,
|
effectiveBuffer: context.effectiveBuffer,
|
||||||
});
|
});
|
||||||
const freeWindows = windowsByDate.get(params.date) ?? [];
|
const freeWindows = windowsByDate.get(params.date) ?? [];
|
||||||
@@ -448,6 +461,18 @@ export async function validateBookingSlot(
|
|||||||
checkMaxAdvance(date, today, context.bookingRule.maxAdvanceDays),
|
checkMaxAdvance(date, today, context.bookingRule.maxAdvanceDays),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const holidayName = (await getClosedDates(prisma, { businessId: context.business.id, fromDate: date, toDate: date })).get(date);
|
||||||
|
if (holidayName !== undefined) {
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
endAt,
|
||||||
|
validation: mergeValidation(
|
||||||
|
ruleChecks,
|
||||||
|
fail({ code: "BUSINESS_HOLIDAY", message: `The business is closed on this date (${holidayName}).` }),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } });
|
const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } });
|
||||||
const businessOpen = businessOpenByWeekdayFrom(businessHours).get(weekdayOf(date));
|
const businessOpen = businessOpenByWeekdayFrom(businessHours).get(weekdayOf(date));
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export interface GetStaffFreeWindowsForRangeParams {
|
|||||||
toDate: CalendarDate;
|
toDate: CalendarDate;
|
||||||
businessTimezone: string;
|
businessTimezone: string;
|
||||||
businessOpenByWeekday: BusinessOpenByWeekday;
|
businessOpenByWeekday: BusinessOpenByWeekday;
|
||||||
|
/** Dates the whole business is closed (holidays) — free windows are empty on these regardless of opening hours. */
|
||||||
|
closedDates?: ReadonlyMap<CalendarDate, string>;
|
||||||
effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
|
effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
|
||||||
excludeAppointmentId?: string;
|
excludeAppointmentId?: string;
|
||||||
}
|
}
|
||||||
@@ -61,8 +63,16 @@ export async function getStaffFreeWindowsForRange(
|
|||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
params: GetStaffFreeWindowsForRangeParams,
|
params: GetStaffFreeWindowsForRangeParams,
|
||||||
): Promise<Map<CalendarDate, TimeWindow[]>> {
|
): Promise<Map<CalendarDate, TimeWindow[]>> {
|
||||||
const { staffId, fromDate, toDate, businessTimezone, businessOpenByWeekday, effectiveBuffer, excludeAppointmentId } =
|
const {
|
||||||
params;
|
staffId,
|
||||||
|
fromDate,
|
||||||
|
toDate,
|
||||||
|
businessTimezone,
|
||||||
|
businessOpenByWeekday,
|
||||||
|
closedDates,
|
||||||
|
effectiveBuffer,
|
||||||
|
excludeAppointmentId,
|
||||||
|
} = params;
|
||||||
|
|
||||||
const rangeStart = startOfBusinessDay(fromDate, businessTimezone);
|
const rangeStart = startOfBusinessDay(fromDate, businessTimezone);
|
||||||
const rangeEnd = endOfBusinessDay(toDate, businessTimezone);
|
const rangeEnd = endOfBusinessDay(toDate, businessTimezone);
|
||||||
@@ -104,7 +114,7 @@ export async function getStaffFreeWindowsForRange(
|
|||||||
const weekday = weekdayOf(date);
|
const weekday = weekdayOf(date);
|
||||||
const businessOpen = businessOpenByWeekday.get(weekday);
|
const businessOpen = businessOpenByWeekday.get(weekday);
|
||||||
|
|
||||||
if (!businessOpen) {
|
if (!businessOpen || closedDates?.has(date)) {
|
||||||
result.set(date, []);
|
result.set(date, []);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const BOOKING_VALIDATION_ERROR_CODES = [
|
|||||||
"SERVICES_SPAN_MULTIPLE_BUSINESSES",
|
"SERVICES_SPAN_MULTIPLE_BUSINESSES",
|
||||||
"STAFF_CANNOT_PERFORM_SERVICE",
|
"STAFF_CANNOT_PERFORM_SERVICE",
|
||||||
"BUSINESS_CLOSED",
|
"BUSINESS_CLOSED",
|
||||||
|
"BUSINESS_HOLIDAY",
|
||||||
"STAFF_NOT_WORKING",
|
"STAFF_NOT_WORKING",
|
||||||
"STAFF_ON_BREAK",
|
"STAFF_ON_BREAK",
|
||||||
"STAFF_ON_LEAVE",
|
"STAFF_ON_LEAVE",
|
||||||
|
|||||||
Reference in new issue
Block a user