- PostgreSQL schema: resources + resources_bookings tables with indexes - Server-side client with PG pool + in-memory fallback for dev - API routes: list, get, availability check, book, cancel - Resource store (Zustand) for client-side state - ResourcePicker component: type filter, search, availability dots - Integrated into event-modal: auto-book on save, auto-cancel on delete - Integrated into free-busy-view: resource availability rows
353 lines
13 KiB
TypeScript
353 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { addMinutes, differenceInMinutes, format } from "date-fns";
|
|
import { Avatar } from "@/components/ui/avatar";
|
|
import { useAuthStore } from "@/stores/auth-store";
|
|
import { cn } from "@/lib/utils";
|
|
import { fetchFreeBusy, type FreeBusySlot, isWorkingHour as isWorkingHourFn } from "@/lib/calendar-freebusy";
|
|
|
|
export interface ResourceFreeBusyEntry {
|
|
id: string;
|
|
name: string;
|
|
availabilityMap: Map<number, FreeBusySlot["status"]>;
|
|
}
|
|
|
|
export interface FreeBusyViewProps {
|
|
participants: { name?: string; email: string }[];
|
|
startDate: Date;
|
|
endDate: Date;
|
|
onTimeSelect?: (start: Date, end: Date) => void;
|
|
resources?: ResourceFreeBusyEntry[];
|
|
}
|
|
|
|
const SLOT_MINUTES = 30;
|
|
const WORK_START_HOUR = 8;
|
|
const WORK_END_HOUR = 18;
|
|
|
|
const statusColors: Record<FreeBusySlot["status"], string> = {
|
|
free: "bg-emerald-100 dark:bg-emerald-900/40 border-emerald-200 dark:border-emerald-800",
|
|
busy: "bg-red-100 dark:bg-red-900/40 border-red-200 dark:border-red-800",
|
|
tentative: "bg-amber-100 dark:bg-amber-900/40 border-amber-200 dark:border-amber-800",
|
|
unavailable: "bg-purple-100 dark:bg-purple-900/40 border-purple-200 dark:border-purple-800",
|
|
unknown: "bg-muted border-muted-foreground/20",
|
|
};
|
|
|
|
const statusHoverColors: Record<FreeBusySlot["status"], string> = {
|
|
free: "hover:bg-emerald-200 dark:hover:bg-emerald-800/60",
|
|
busy: "hover:bg-red-200 dark:hover:bg-red-800/60",
|
|
tentative: "hover:bg-amber-200 dark:hover:bg-amber-800/60",
|
|
unavailable: "hover:bg-purple-200 dark:hover:bg-purple-800/60",
|
|
unknown: "hover:bg-muted-foreground/20",
|
|
};
|
|
|
|
function clampToSlot(d: Date): Date {
|
|
const clone = new Date(d);
|
|
clone.setSeconds(0, 0);
|
|
const mins = clone.getMinutes();
|
|
const remainder = mins % SLOT_MINUTES;
|
|
if (remainder !== 0) {
|
|
clone.setMinutes(mins - remainder, 0, 0);
|
|
}
|
|
return clone;
|
|
}
|
|
|
|
function buildHourSlots(start: Date, end: Date): { label: string; slots: FreeBusySlot[] }[] {
|
|
const hours: { label: string; slots: FreeBusySlot[] }[] = [];
|
|
let cursor = clampToSlot(start);
|
|
while (cursor < end) {
|
|
const hourEnd = new Date(cursor);
|
|
hourEnd.setHours(hourEnd.getHours() + 1, 0, 0, 0);
|
|
const hourSlots: FreeBusySlot[] = [];
|
|
let slotCursor = new Date(cursor);
|
|
while (slotCursor < hourEnd && slotCursor < end) {
|
|
const slotEnd = addMinutes(slotCursor, SLOT_MINUTES);
|
|
hourSlots.push({
|
|
start: new Date(slotCursor),
|
|
end: slotEnd > end ? new Date(end) : slotEnd,
|
|
status: "unknown",
|
|
});
|
|
slotCursor = slotEnd;
|
|
}
|
|
hours.push({ label: format(cursor, "HH:mm"), slots: hourSlots });
|
|
cursor = hourEnd;
|
|
}
|
|
return hours;
|
|
}
|
|
|
|
function isWorkingHour(hour: number): boolean {
|
|
return isWorkingHourFn(hour, WORK_START_HOUR, WORK_END_HOUR);
|
|
}
|
|
|
|
export function FreeBusyView({
|
|
participants,
|
|
startDate,
|
|
endDate,
|
|
onTimeSelect,
|
|
resources = [],
|
|
}: FreeBusyViewProps) {
|
|
const t = useTranslations("calendar");
|
|
const client = useAuthStore((s) => s.client);
|
|
const [freeBusyData, setFreeBusyData] = useState<Map<string, FreeBusySlot[]> | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [hoveredSlot, setHoveredSlot] = useState<{
|
|
participant: string;
|
|
slotIndex: number;
|
|
} | null>(null);
|
|
|
|
const hourSlots = useMemo(() => buildHourSlots(startDate, endDate), [startDate, endDate]);
|
|
const totalHalfHourSlots = useMemo(() => {
|
|
let c = 0;
|
|
for (const h of hourSlots) c += h.slots.length;
|
|
return c;
|
|
}, [hourSlots]);
|
|
|
|
const now = new Date();
|
|
const showNowLine =
|
|
now >= startDate && now <= endDate;
|
|
const nowPositionPercent = showNowLine
|
|
? Math.max(0, Math.min(100, (differenceInMinutes(now, startDate) / differenceInMinutes(endDate, startDate)) * 100))
|
|
: null;
|
|
|
|
useEffect(() => {
|
|
if (!client || participants.length === 0) return;
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
fetchFreeBusy(client, participants, startDate, endDate)
|
|
.then((data) => {
|
|
if (!cancelled) {
|
|
setFreeBusyData(data);
|
|
setLoading(false);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [client, participants, startDate, endDate]);
|
|
|
|
const handleSlotClick = useCallback(
|
|
(slot: FreeBusySlot) => {
|
|
if (slot.status === "free" && onTimeSelect) {
|
|
onTimeSelect(new Date(slot.start), new Date(slot.end));
|
|
}
|
|
},
|
|
[onTimeSelect]
|
|
);
|
|
|
|
const timezone = useMemo(
|
|
() => Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
[]
|
|
);
|
|
|
|
if (participants.length === 0) {
|
|
return (
|
|
<p className="text-sm text-muted-foreground py-4 text-center">
|
|
{t("freeBusy.no_participants")}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-xs text-muted-foreground">
|
|
{t("freeBusy.timezone")}: {timezone}
|
|
</div>
|
|
{loading && (
|
|
<div className="text-xs text-muted-foreground animate-pulse">
|
|
{t("freeBusy.loading")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="overflow-auto border border-border rounded-lg">
|
|
<div className="min-w-max" style={{ minWidth: totalHalfHourSlots * 24 + 200 }}>
|
|
<table className="w-full border-collapse text-xs">
|
|
<thead>
|
|
<tr>
|
|
<th className="sticky left-0 z-10 bg-background border-b border-r border-border px-3 py-2 text-left w-[180px] min-w-[180px]">
|
|
{t("participants.title")}
|
|
</th>
|
|
{hourSlots.map((hour, i) => (
|
|
<th
|
|
key={i}
|
|
colSpan={hour.slots.length}
|
|
className={cn(
|
|
"border-b border-r border-border px-1 py-2 text-center font-medium",
|
|
isWorkingHour(new Date(hour.slots[0]?.start).getHours())
|
|
? "bg-muted/50"
|
|
: "bg-muted/20"
|
|
)}
|
|
>
|
|
{hour.label}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{participants.map((p) => {
|
|
const key = p.email.toLowerCase();
|
|
const slots = freeBusyData?.get(key);
|
|
return (
|
|
<tr key={key} className="border-b border-border">
|
|
<td className="sticky left-0 z-10 bg-background border-r border-border px-3 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<Avatar
|
|
name={p.name}
|
|
email={p.email}
|
|
size="sm"
|
|
className="shrink-0"
|
|
/>
|
|
<div className="min-w-0">
|
|
<div className="font-medium truncate">
|
|
{p.name || p.email}
|
|
</div>
|
|
{p.name && (
|
|
<div className="text-[10px] text-muted-foreground truncate">
|
|
{p.email}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
{hourSlots.map((hour) =>
|
|
hour.slots.map((hourSlot, si) => {
|
|
const globalSlotIndex =
|
|
hourSlots
|
|
.slice(0, hourSlots.indexOf(hour))
|
|
.reduce((acc, h) => acc + h.slots.length, 0) + si;
|
|
|
|
const slot = slots?.[globalSlotIndex];
|
|
const status = slot?.status ?? "unknown";
|
|
const isFree = status === "free";
|
|
const isHovered =
|
|
hoveredSlot?.participant === key &&
|
|
hoveredSlot?.slotIndex === globalSlotIndex;
|
|
|
|
return (
|
|
<td
|
|
key={si}
|
|
className={cn(
|
|
"border-r border-border py-1 text-center relative cursor-default transition-colors",
|
|
statusColors[status],
|
|
isFree && statusHoverColors[status],
|
|
isFree && "cursor-pointer",
|
|
isHovered && "ring-1 ring-inset ring-primary/50",
|
|
isWorkingHour(new Date(hourSlot.start).getHours())
|
|
? ""
|
|
: "opacity-70"
|
|
)}
|
|
title={format(hourSlot.start, "HH:mm")}
|
|
onClick={() =>
|
|
isFree ? handleSlotClick(slot!) : undefined
|
|
}
|
|
onMouseEnter={() =>
|
|
setHoveredSlot({
|
|
participant: key,
|
|
slotIndex: globalSlotIndex,
|
|
})
|
|
}
|
|
onMouseLeave={() => setHoveredSlot(null)}
|
|
>
|
|
{status === "free" && (
|
|
<span className="block w-full h-full"> </span>
|
|
)}
|
|
</td>
|
|
);
|
|
})
|
|
)}
|
|
</tr>
|
|
);
|
|
})}
|
|
{resources.map((res) => (
|
|
<tr key={`res-${res.id}`} className="border-b border-border">
|
|
<td className="sticky left-0 z-10 bg-background border-r border-border px-3 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-6 h-6 rounded bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center shrink-0">
|
|
<span className="text-[10px] font-bold text-blue-600 dark:text-blue-400">
|
|
R
|
|
</span>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="font-medium truncate text-sm">
|
|
{res.name}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
{hourSlots.map((hour) =>
|
|
hour.slots.map((hourSlot, si) => {
|
|
const globalSlotIndex =
|
|
hourSlots
|
|
.slice(0, hourSlots.indexOf(hour))
|
|
.reduce((acc, h) => acc + h.slots.length, 0) + si;
|
|
|
|
const status = res.availabilityMap.get(globalSlotIndex) ?? "unknown";
|
|
const isFree = status === "free";
|
|
|
|
return (
|
|
<td
|
|
key={si}
|
|
className={cn(
|
|
"border-r border-border py-1 text-center relative cursor-default transition-colors",
|
|
statusColors[status],
|
|
isFree && "cursor-pointer",
|
|
isWorkingHour(new Date(hourSlot.start).getHours())
|
|
? ""
|
|
: "opacity-70"
|
|
)}
|
|
title={`${res.name} - ${format(hourSlot.start, "HH:mm")}`}
|
|
>
|
|
{status === "free" && (
|
|
<span className="block w-full h-full"> </span>
|
|
)}
|
|
</td>
|
|
);
|
|
})
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{showNowLine && nowPositionPercent !== null && (
|
|
<div
|
|
className="absolute pointer-events-none z-20"
|
|
style={{
|
|
left: `calc(180px + ${nowPositionPercent}% * (1 - 180px / ${totalHalfHourSlots * 24 + 200}))`,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
|
|
<span className="inline-flex items-center gap-1">
|
|
<span className="w-3 h-3 rounded border border-emerald-200 dark:border-emerald-800 bg-emerald-100 dark:bg-emerald-900/40" />
|
|
{t("freeBusy.free")}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1">
|
|
<span className="w-3 h-3 rounded border border-red-200 dark:border-red-800 bg-red-100 dark:bg-red-900/40" />
|
|
{t("freeBusy.busy")}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1">
|
|
<span className="w-3 h-3 rounded border border-amber-200 dark:border-amber-800 bg-amber-100 dark:bg-amber-900/40" />
|
|
{t("freeBusy.tentative")}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1">
|
|
<span className="w-3 h-3 rounded border border-purple-200 dark:border-purple-800 bg-purple-100 dark:bg-purple-900/40" />
|
|
{t("freeBusy.unavailable")}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1">
|
|
<span className="w-3 h-3 rounded border border-muted-foreground/20 bg-muted" />
|
|
{t("freeBusy.unknown")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|