690 files changed
+12045
-33943
No files matched your search
@@ -0,0 +1,213 @@
|
||||
"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 { saveWorkingHoursAction } from "../actions/working-hours";
|
||||
import type { WorkingHoursActionResult, WorkingHoursConflictItem } from "../actions/working-hours";
|
||||
import type { WorkingHoursDayInput } from "@/services/staff/staff.service";
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
interface DayState {
|
||||
dayOfWeek: Weekday;
|
||||
isOff: boolean;
|
||||
startTime: string; // "HH:MM"
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export interface InitialWorkingHoursRow {
|
||||
dayOfWeek: Weekday;
|
||||
isOff: boolean;
|
||||
startMinutes: number | null;
|
||||
endMinutes: number | null;
|
||||
}
|
||||
|
||||
export function WorkingHoursForm({
|
||||
initialRows,
|
||||
businessHoursLabel,
|
||||
onSave = saveWorkingHoursAction,
|
||||
appointmentHrefBase = "/staff/appointments",
|
||||
}: {
|
||||
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[],
|
||||
acknowledgeConflicts?: boolean,
|
||||
) => Promise<WorkingHoursActionResult>;
|
||||
/** Conflict links point at /owner/appointments/[id] when the owner is editing on a staff member's behalf. */
|
||||
appointmentHrefBase?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [days, setDays] = useState<DayState[]>(() =>
|
||||
DAY_ORDER.map((dayOfWeek) => {
|
||||
const existing = initialRows.find((r) => r.dayOfWeek === dayOfWeek);
|
||||
return {
|
||||
dayOfWeek,
|
||||
isOff: existing?.isOff ?? true,
|
||||
startTime: toTimeString(existing?.startMinutes ?? null),
|
||||
endTime: toTimeString(existing?.endMinutes ?? 19 * 60),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [conflicts, setConflicts] = useState<WorkingHoursConflictItem[]>([]);
|
||||
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, isOff: monday.isOff, startTime: monday.startTime, endTime: monday.endTime })));
|
||||
};
|
||||
|
||||
const submit = (acknowledgeConflicts: boolean) => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await onSave(
|
||||
days.map((d) => ({
|
||||
dayOfWeek: d.dayOfWeek,
|
||||
isOff: d.isOff,
|
||||
...(d.isOff ? {} : { startMinutes: toMinutes(d.startTime), endMinutes: toMinutes(d.endTime) }),
|
||||
})),
|
||||
acknowledgeConflicts,
|
||||
);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't save working 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
|
||||
working 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={`${appointmentHrefBase}/${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={() => 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-md 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.isOff} onCheckedChange={(checked) => updateDay(day.dayOfWeek, { isOff: !checked })} aria-label={`${DAY_LABEL[day.dayOfWeek]} working`} />
|
||||
<div>
|
||||
<p className="text-sm font-bold">{DAY_LABEL[day.dayOfWeek]}</p>
|
||||
<p className="text-muted text-[10px]">Business hours: {businessHoursLabel[day.dayOfWeek]}</p>
|
||||
</div>
|
||||
</div>
|
||||
{day.isOff ? (
|
||||
<span className="text-muted text-xs">Off</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]} start 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]} end 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-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user