feat: P2.8 Resources/Equipment Booking (PostgreSQL + VNCdirectory)
- 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
This commit is contained in:
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus, Eye, EyeOff, ExternalLink, Reply, ReplyAll, Globe } from "lucide-react";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus, Eye, EyeOff, ExternalLink, Reply, ReplyAll, Globe, Building2 } from "lucide-react";
|
||||
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
||||
@@ -28,6 +28,8 @@ import { calendarHooks } from "@/lib/plugin-hooks";
|
||||
import type { ConflictWarning } from "@/lib/plugin-types";
|
||||
import { RecipientPopover } from "@/components/email/recipient-popover";
|
||||
import { useProTabStore } from "@/stores/pro-tab-store";
|
||||
import { ResourcePicker } from "./resource-picker";
|
||||
import { useResourceStore } from "@/stores/resource-store";
|
||||
|
||||
export interface PendingEventPreview {
|
||||
start: Date;
|
||||
@@ -383,6 +385,9 @@ export function EventModal({
|
||||
});
|
||||
const openComposeTab = useProTabStore((s) => s.openComposeTab);
|
||||
|
||||
const resourceStore = useResourceStore();
|
||||
const [showResources, setShowResources] = useState(false);
|
||||
|
||||
// Plugin transform: collect conflict warnings for the current event form.
|
||||
// Re-runs (debounced) whenever fields that affect scheduling change.
|
||||
const [pluginConflictWarnings, setPluginConflictWarnings] = useState<ConflictWarning[]>([]);
|
||||
@@ -408,6 +413,13 @@ export function EventModal({
|
||||
return () => { cancelled = true; clearTimeout(t); };
|
||||
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (event?.id) {
|
||||
resourceStore.fetchEventBookings(event.id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [event?.id]);
|
||||
|
||||
// Report live preview to parent for grid outline
|
||||
useEffect(() => {
|
||||
if (!onPreviewChange || isEdit) return;
|
||||
@@ -633,10 +645,20 @@ export function EventModal({
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(data, shouldSendScheduling);
|
||||
if (resourceStore.selectedResources.length > 0) {
|
||||
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
|
||||
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
|
||||
const eventRef = event?.id || data.uid;
|
||||
await resourceStore.bookSelectedResources(
|
||||
startStr,
|
||||
endStr,
|
||||
eventRef,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving, createVncMeeting, timezone]);
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving, createVncMeeting, timezone, resourceStore]);
|
||||
|
||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||
if (!event || !userParticipantId || !onRsvp) return;
|
||||
@@ -1054,6 +1076,26 @@ export function EventModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resources (booked) */}
|
||||
{resourceStore.bookings.length > 0 && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Building2 className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{resourceStore.bookings.map((b) => {
|
||||
const res = resourceStore.resources.find((r) => r.id === b.resourceId);
|
||||
return (
|
||||
<span
|
||||
key={b.id}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs font-medium"
|
||||
>
|
||||
{res?.name || b.resourceId}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timezone */}
|
||||
{!event.showWithoutTime && event.timeZone && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
@@ -1111,7 +1153,7 @@ export function EventModal({
|
||||
showDeleteConfirm ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-destructive">{t("form.delete_confirm")}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => { onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-destructive border-destructive/30">
|
||||
<Button variant="outline" size="sm" onClick={() => { resourceStore.cancelEventBookings(event.id); onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-destructive border-destructive/30">
|
||||
{t("events.delete")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||
@@ -1310,6 +1352,27 @@ export function EventModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setShowResources((prev) => !prev)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Building2 className="w-3.5 h-3.5 me-1" />
|
||||
{showResources ? t("resources.hide") : t("resources.title")}
|
||||
</Button>
|
||||
{showResources && (
|
||||
<div className="mt-3">
|
||||
<ResourcePicker
|
||||
start={allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`}
|
||||
end={allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -1573,7 +1636,7 @@ export function EventModal({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
||||
onClick={() => { resourceStore.cancelEventBookings(event!.id); onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
||||
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
|
||||
>
|
||||
{t("events.delete")}
|
||||
|
||||
@@ -8,11 +8,18 @@ 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;
|
||||
@@ -78,6 +85,7 @@ export function FreeBusyView({
|
||||
startDate,
|
||||
endDate,
|
||||
onTimeSelect,
|
||||
resources = [],
|
||||
}: FreeBusyViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const client = useAuthStore((s) => s.client);
|
||||
@@ -255,6 +263,54 @@ export function FreeBusyView({
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useResourceStore } from "@/stores/resource-store";
|
||||
import type { Resource } from "@/lib/resources/client";
|
||||
import {
|
||||
Building2,
|
||||
Car,
|
||||
Wrench,
|
||||
Box,
|
||||
MapPin,
|
||||
Users,
|
||||
Search,
|
||||
X,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ResourcePickerProps {
|
||||
start?: string;
|
||||
end?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const typeIcons: Record<Resource["type"], typeof Building2> = {
|
||||
room: Building2,
|
||||
vehicle: Car,
|
||||
equipment: Wrench,
|
||||
other: Box,
|
||||
};
|
||||
|
||||
type TypeFilter = "all" | Resource["type"];
|
||||
|
||||
export function ResourcePicker({ start, end, compact = false }: ResourcePickerProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const {
|
||||
resources,
|
||||
selectedResources,
|
||||
isLoading,
|
||||
fetchResources,
|
||||
searchResources,
|
||||
toggleResource,
|
||||
deselectResource,
|
||||
clearSelection,
|
||||
} = useResourceStore();
|
||||
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [availabilityMap, setAvailabilityMap] = useState<Record<string, "available" | "conflict" | "unknown">>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchResources();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = typeFilter === "all" ? resources : resources.filter((r) => r.type === typeFilter);
|
||||
if (query.trim()) {
|
||||
list = searchResources(query).filter((r) => typeFilter === "all" || r.type === typeFilter);
|
||||
}
|
||||
return list;
|
||||
}, [resources, typeFilter, query, searchResources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!start || !end) return;
|
||||
let cancelled = false;
|
||||
|
||||
const checkAll = async () => {
|
||||
const map: Record<string, "available" | "conflict" | "unknown"> = {};
|
||||
for (const resource of filtered) {
|
||||
try {
|
||||
const params = new URLSearchParams({ start, end });
|
||||
const { apiFetch } = await import("@/lib/browser-navigation");
|
||||
const res = await apiFetch(
|
||||
`/api/resources/${resource.id}/availability?${params.toString()}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
map[resource.id] = data.available ? "available" : "conflict";
|
||||
} else {
|
||||
map[resource.id] = "unknown";
|
||||
}
|
||||
} catch {
|
||||
map[resource.id] = "unknown";
|
||||
}
|
||||
}
|
||||
if (!cancelled) setAvailabilityMap(map);
|
||||
};
|
||||
|
||||
checkAll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filtered, start, end]);
|
||||
|
||||
const filters: { key: TypeFilter; label: string }[] = [
|
||||
{ key: "all", label: t("resources.filter_all") },
|
||||
{ key: "room", label: t("resources.type_room") },
|
||||
{ key: "vehicle", label: t("resources.type_vehicle") },
|
||||
{ key: "equipment", label: t("resources.type_equipment") },
|
||||
{ key: "other", label: t("resources.type_other") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
onClick={() => setTypeFilter(f.key)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-medium transition-colors",
|
||||
typeFilter === f.key
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("resources.search_placeholder")}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t("resources.no_resources")}
|
||||
</p>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"border border-border rounded-lg divide-y divide-border",
|
||||
!compact && "max-h-64 overflow-y-auto"
|
||||
)}>
|
||||
{filtered.map((resource) => {
|
||||
const TypeIcon = typeIcons[resource.type];
|
||||
const isSelected = selectedResources.some((r) => r.id === resource.id);
|
||||
const avail = availabilityMap[resource.id] || "unknown";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={resource.id}
|
||||
type="button"
|
||||
onClick={() => toggleResource(resource)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/10 hover:bg-primary/15"
|
||||
: "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<span className="relative flex-shrink-0">
|
||||
<TypeIcon className="w-5 h-5 text-muted-foreground" />
|
||||
{start && end && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-background",
|
||||
avail === "available" && "bg-emerald-500",
|
||||
avail === "conflict" && "bg-red-500",
|
||||
avail === "unknown" && "bg-muted-foreground/40"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">{resource.name}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{resource.location && (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{resource.location}
|
||||
</span>
|
||||
)}
|
||||
{resource.capacity != null && resource.capacity > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<Users className="w-3 h-3" />
|
||||
{resource.capacity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors",
|
||||
isSelected
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "border-muted-foreground/40"
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="w-3.5 h-3.5" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedResources.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{selectedResources.map((resource) => (
|
||||
<span
|
||||
key={resource.id}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary px-2.5 py-1 text-xs font-medium"
|
||||
>
|
||||
{resource.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deselectResource(resource.id)}
|
||||
className="ml-0.5 rounded-full p-0.5 hover:bg-primary/20 transition-colors"
|
||||
aria-label={t("resources.remove", { name: resource.name })}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{selectedResources.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
className="text-xs text-muted-foreground hover:text-foreground ml-1"
|
||||
>
|
||||
{t("resources.clear_all")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user