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")}
|
||||
|
||||
Reference in New Issue
Block a user