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:
Bernd Rodler
2026-08-07 13:45:09 +02:00
parent 13ec05da83
commit 4fcd37650d
12 changed files with 1030 additions and 4 deletions
+67 -4
View File
@@ -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")}