178 lines
6.2 KiB
TypeScript
178 lines
6.2 KiB
TypeScript
"use client";
|
|
|
|
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 { TimeOffActionResult, TimeOffConflictItem } from "../actions/time-off";
|
|
import { createTimeOffAction, deleteTimeOffAction, updateTimeOffAction } from "../actions/time-off";
|
|
import type { TimeOffInput } from "@/services/staff/staff.service";
|
|
|
|
export interface EditableTimeOff {
|
|
id: string;
|
|
startDate: string;
|
|
endDate: string;
|
|
reason: string | null;
|
|
}
|
|
|
|
/** Same DI shape as AppointmentStatusActions' `actions` prop — the owner-on-behalf-of-staff editor injects its own Server Actions bound to a chosen staffId instead of forking this component. */
|
|
export interface TimeOffActionSet {
|
|
create: (
|
|
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
|
acknowledgeConflicts?: boolean,
|
|
) => Promise<TimeOffActionResult>;
|
|
update: (
|
|
timeOffId: string,
|
|
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
|
acknowledgeConflicts?: boolean,
|
|
) => Promise<TimeOffActionResult>;
|
|
remove: (timeOffId: string) => Promise<TimeOffActionResult>;
|
|
}
|
|
|
|
const DEFAULT_ACTIONS: TimeOffActionSet = {
|
|
create: createTimeOffAction,
|
|
update: updateTimeOffAction,
|
|
remove: deleteTimeOffAction,
|
|
};
|
|
|
|
function todayIso(): string {
|
|
return new Date().toISOString().slice(0, 10);
|
|
}
|
|
|
|
export function TimeOffFormSheet({
|
|
open,
|
|
onClose,
|
|
existing,
|
|
actions = DEFAULT_ACTIONS,
|
|
appointmentHrefBase = "/staff/appointments",
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
existing?: EditableTimeOff | null;
|
|
actions?: TimeOffActionSet;
|
|
appointmentHrefBase?: string;
|
|
}) {
|
|
const router = useRouter();
|
|
const [startDate, setStartDate] = useState(existing?.startDate ?? todayIso());
|
|
const [endDate, setEndDate] = useState(existing?.endDate ?? existing?.startDate ?? todayIso());
|
|
const [reason, setReason] = useState(existing?.reason ?? "");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [conflicts, setConflicts] = useState<TimeOffConflictItem[] | null>(null);
|
|
const [isPending, startTransition] = useTransition();
|
|
const [isDeleting, startDeleteTransition] = useTransition();
|
|
|
|
const reset = () => {
|
|
setStartDate(existing?.startDate ?? todayIso());
|
|
setEndDate(existing?.endDate ?? existing?.startDate ?? todayIso());
|
|
setReason(existing?.reason ?? "");
|
|
setError(null);
|
|
setConflicts(null);
|
|
};
|
|
|
|
const handleClose = () => {
|
|
onClose();
|
|
reset();
|
|
};
|
|
|
|
const submit = (acknowledgeConflicts: boolean) => {
|
|
if (startDate > endDate) {
|
|
setError("Start date must be on or before the end date.");
|
|
return;
|
|
}
|
|
setError(null);
|
|
startTransition(async () => {
|
|
const input = { startDate, endDate, reason: reason.trim() || undefined };
|
|
const result: TimeOffActionResult = existing
|
|
? await actions.update(existing.id, input, acknowledgeConflicts)
|
|
: await actions.create(input, acknowledgeConflicts);
|
|
if (!result.success) {
|
|
setError(result.error ?? "Something went wrong.");
|
|
setConflicts(result.conflicts ?? null);
|
|
return;
|
|
}
|
|
handleClose();
|
|
router.refresh();
|
|
});
|
|
};
|
|
|
|
const handleDelete = () => {
|
|
if (!existing) return;
|
|
startDeleteTransition(async () => {
|
|
const result = await actions.remove(existing.id);
|
|
if (!result.success) {
|
|
setError(result.error ?? "Couldn't remove this time off.");
|
|
return;
|
|
}
|
|
handleClose();
|
|
router.refresh();
|
|
});
|
|
};
|
|
|
|
return (
|
|
<BottomSheet open={open} onClose={handleClose} title={existing ? "Edit Time Off" : "New Time Off"}>
|
|
<div className="space-y-4 pb-2">
|
|
{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" : ""}. These won't be cancelled automatically —
|
|
you'll need to reschedule or cancel them yourself.
|
|
</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={handleClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
className="flex-1"
|
|
isLoading={isPending}
|
|
onClick={() => submit(true)}
|
|
>
|
|
Save Anyway
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Input label="Start date" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
|
<Input label="End date" type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
|
</div>
|
|
<Input
|
|
label="Reason (optional)"
|
|
placeholder="e.g. Vacation"
|
|
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)}>
|
|
{existing ? "Save Changes" : "Add Time Off"}
|
|
</Button>
|
|
{existing && (
|
|
<Button variant="ghost" className="w-full text-danger" isLoading={isDeleting} onClick={handleDelete}>
|
|
Remove Time Off
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</BottomSheet>
|
|
);
|
|
}
|