148 lines
5.0 KiB
TypeScript
148 lines
5.0 KiB
TypeScript
"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 { BlockTimeConflict } from "../actions/block-time";
|
|
import { blockTimeAction } from "../actions/block-time";
|
|
|
|
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]);
|
|
}
|
|
|
|
export function BlockTimeButton({ date }: { 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<BlockTimeConflict[] | 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 blockTimeAction({
|
|
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" aria-label="Block Time" onClick={() => setOpen(true)}>
|
|
<CalendarOff className="h-4 w-4" aria-hidden="true" />
|
|
<span className="hidden sm:inline">Block Time</span>
|
|
</Button>
|
|
<BottomSheet open={open} onClose={handleClose} title="Block Time">
|
|
<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",
|
|
})}{" "}
|
|
for something other than appointments — a meeting, admin, a personal break.
|
|
</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={`/staff/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>
|
|
</>
|
|
);
|
|
}
|