From 4fcd37650d23c550d67a523c9e4c98dc45bc76b0 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Fri, 7 Aug 2026 13:45:09 +0200 Subject: [PATCH] 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 --- app/api/resources/[id]/availability/route.ts | 31 ++ .../resources/[id]/book/[bookingId]/route.ts | 24 ++ app/api/resources/[id]/book/route.ts | 40 +++ app/api/resources/[id]/route.ts | 27 ++ app/api/resources/route.ts | 59 ++++ components/calendar/event-modal.tsx | 71 ++++- components/calendar/free-busy-view.tsx | 56 ++++ components/calendar/resource-picker.tsx | 243 +++++++++++++++ lib/resources/client.ts | 287 ++++++++++++++++++ lib/resources/schema.sql | 29 ++ locales/en/common.json | 13 + stores/resource-store.ts | 154 ++++++++++ 12 files changed, 1030 insertions(+), 4 deletions(-) create mode 100644 app/api/resources/[id]/availability/route.ts create mode 100644 app/api/resources/[id]/book/[bookingId]/route.ts create mode 100644 app/api/resources/[id]/book/route.ts create mode 100644 app/api/resources/[id]/route.ts create mode 100644 app/api/resources/route.ts create mode 100644 components/calendar/resource-picker.tsx create mode 100644 lib/resources/client.ts create mode 100644 lib/resources/schema.sql create mode 100644 stores/resource-store.ts diff --git a/app/api/resources/[id]/availability/route.ts b/app/api/resources/[id]/availability/route.ts new file mode 100644 index 00000000..33df9b73 --- /dev/null +++ b/app/api/resources/[id]/availability/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { checkAvailability } from '@/lib/resources/client'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const creds = await getStalwartCredentials(request); + if (!creds) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const { searchParams } = new URL(request.url); + const start = searchParams.get('start'); + const end = searchParams.get('end'); + + if (!start || !end) { + return NextResponse.json({ error: 'start and end query parameters are required' }, { status: 400 }); + } + + const result = await checkAvailability(id, start, end); + return NextResponse.json(result); + } catch (error) { + logger.error('Resource availability error', { error: error instanceof Error ? error.message : 'Unknown' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/resources/[id]/book/[bookingId]/route.ts b/app/api/resources/[id]/book/[bookingId]/route.ts new file mode 100644 index 00000000..fa633e81 --- /dev/null +++ b/app/api/resources/[id]/book/[bookingId]/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { cancelBooking } from '@/lib/resources/client'; + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string; bookingId: string }> }, +) { + try { + const creds = await getStalwartCredentials(request); + if (!creds) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { bookingId } = await params; + await cancelBooking(bookingId); + + return NextResponse.json({ ok: true }); + } catch (error) { + logger.error('Resource booking cancel error', { error: error instanceof Error ? error.message : 'Unknown' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/resources/[id]/book/route.ts b/app/api/resources/[id]/book/route.ts new file mode 100644 index 00000000..8c86fc8b --- /dev/null +++ b/app/api/resources/[id]/book/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { bookResource, checkAvailability, getResource } from '@/lib/resources/client'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const creds = await getStalwartCredentials(request); + if (!creds) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const { start, end, eventId } = body; + + if (!start || !end) { + return NextResponse.json({ error: 'start and end are required' }, { status: 400 }); + } + + const resource = await getResource(id); + if (!resource) { + return NextResponse.json({ error: 'Resource not found' }, { status: 404 }); + } + + const { available, conflicts } = await checkAvailability(id, start, end); + if (!available) { + return NextResponse.json({ error: 'Resource is not available for the requested time', conflicts }, { status: 409 }); + } + + const booking = await bookResource(id, start, end, creds.username, eventId); + return NextResponse.json({ booking }, { status: 201 }); + } catch (error) { + logger.error('Resource booking error', { error: error instanceof Error ? error.message : 'Unknown' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/resources/[id]/route.ts b/app/api/resources/[id]/route.ts new file mode 100644 index 00000000..5182cae8 --- /dev/null +++ b/app/api/resources/[id]/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { getResource } from '@/lib/resources/client'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const creds = await getStalwartCredentials(request); + if (!creds) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const resource = await getResource(id); + if (!resource) { + return NextResponse.json({ error: 'Resource not found' }, { status: 404 }); + } + + return NextResponse.json({ resource }); + } catch (error) { + logger.error('Resource get error', { error: error instanceof Error ? error.message : 'Unknown' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/resources/route.ts b/app/api/resources/route.ts new file mode 100644 index 00000000..8fc68aa0 --- /dev/null +++ b/app/api/resources/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { listResources, createResource, getBookingsForEvent } from '@/lib/resources/client'; + +export async function GET(request: NextRequest) { + try { + const creds = await getStalwartCredentials(request); + if (!creds) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const type = searchParams.get('type') || undefined; + const eventId = searchParams.get('eventId') || undefined; + + if (eventId) { + const bookings = await getBookingsForEvent(eventId); + return NextResponse.json({ bookings }); + } + + const resources = await listResources(creds.username, type); + return NextResponse.json({ resources }); + } catch (error) { + logger.error('Resources list error', { error: error instanceof Error ? error.message : 'Unknown' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const creds = await getStalwartCredentials(request); + if (!creds) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const body = await request.json(); + const { name, type, location, capacity, description, contactEmail, metadata } = body; + + if (!name || !type || !['room', 'vehicle', 'equipment', 'other'].includes(type)) { + return NextResponse.json({ error: 'Name and valid type are required' }, { status: 400 }); + } + + const resource = await createResource(creds.username, { + name, + type, + location, + capacity: capacity ? Number(capacity) : undefined, + description, + contactEmail, + metadata, + }); + + return NextResponse.json({ resource }, { status: 201 }); + } catch (error) { + logger.error('Resource create error', { error: error instanceof Error ? error.message : 'Unknown' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 37f7c371..27662dec 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -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([]); @@ -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({ )} + {/* Resources (booked) */} + {resourceStore.bookings.length > 0 && ( +
+ +
+ {resourceStore.bookings.map((b) => { + const res = resourceStore.resources.find((r) => r.id === b.resourceId); + return ( + + {res?.name || b.resourceId} + + ); + })} +
+
+ )} + {/* Timezone */} {!event.showWithoutTime && event.timeZone && (
@@ -1111,7 +1153,7 @@ export function EventModal({ showDeleteConfirm ? (
{t("form.delete_confirm")} -
+
+ + {showResources && ( +
+ +
+ )} +
+
{ 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")} diff --git a/components/calendar/free-busy-view.tsx b/components/calendar/free-busy-view.tsx index 69fa5f5b..74efbbac 100644 --- a/components/calendar/free-busy-view.tsx +++ b/components/calendar/free-busy-view.tsx @@ -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; +} + 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({ ); })} + {resources.map((res) => ( + + +
+
+ + R + +
+
+
+ {res.name} +
+
+
+ + {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 ( + + {status === "free" && ( +   + )} + + ); + }) + )} + + ))}
diff --git a/components/calendar/resource-picker.tsx b/components/calendar/resource-picker.tsx new file mode 100644 index 00000000..febb1af1 --- /dev/null +++ b/components/calendar/resource-picker.tsx @@ -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 = { + 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("all"); + const [query, setQuery] = useState(""); + const [availabilityMap, setAvailabilityMap] = useState>({}); + + 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 = {}; + 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 ( +
+
+ {filters.map((f) => ( + + ))} +
+ +
+ + setQuery(e.target.value)} + placeholder={t("resources.search_placeholder")} + className="pl-8" + /> +
+ + {isLoading ? ( +
+
+
+ ) : filtered.length === 0 ? ( +

+ {t("resources.no_resources")} +

+ ) : ( +
+ {filtered.map((resource) => { + const TypeIcon = typeIcons[resource.type]; + const isSelected = selectedResources.some((r) => r.id === resource.id); + const avail = availabilityMap[resource.id] || "unknown"; + + return ( + + ); + })} +
+ )} + + {selectedResources.length > 0 && ( +
+ {selectedResources.map((resource) => ( + + {resource.name} + + + ))} + {selectedResources.length > 0 && ( + + )} +
+ )} +
+ ); +} diff --git a/lib/resources/client.ts b/lib/resources/client.ts new file mode 100644 index 00000000..6b2db52c --- /dev/null +++ b/lib/resources/client.ts @@ -0,0 +1,287 @@ +import { generateUUID } from '@/lib/utils'; + +export interface Resource { + id: string; + tenantId: string; + name: string; + type: 'room' | 'vehicle' | 'equipment' | 'other'; + location?: string; + capacity?: number; + description?: string; + contactEmail?: string; + isActive: boolean; + metadata: Record; +} + +export interface ResourceBooking { + id: string; + resourceId: string; + eventId?: string; + startTime: string; + endTime: string; + bookedBy: string; +} + +interface ResourceRow { + id: string; + tenant_id: string; + name: string; + type: string; + location: string | null; + capacity: number | null; + description: string | null; + contact_email: string | null; + is_active: boolean; + metadata: Record; +} + +interface BookingRow { + id: string; + resource_id: string; + event_id: string | null; + start_time: string; + end_time: string; + booked_by: string; +} + +function rowToResource(row: ResourceRow): Resource { + return { + id: row.id, + tenantId: row.tenant_id, + name: row.name, + type: row.type as Resource['type'], + location: row.location ?? undefined, + capacity: row.capacity ?? undefined, + description: row.description ?? undefined, + contactEmail: row.contact_email ?? undefined, + isActive: row.is_active, + metadata: row.metadata ?? {}, + }; +} + +function rowToBooking(row: BookingRow): ResourceBooking { + return { + id: row.id, + resourceId: row.resource_id, + eventId: row.event_id ?? undefined, + startTime: row.start_time, + endTime: row.end_time, + bookedBy: row.booked_by, + }; +} + +let pool: { query: (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }> } | null = null; + +async function getPool(): Promise<{ query: (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }> } | null> { + if (pool) return pool; + const url = process.env.DATABASE_URL; + if (url) { + try { + // @ts-expect-error - pg is an optional runtime dependency, not in package.json + const pg = (await import('pg')) as unknown as { Pool?: new (cfg: { connectionString: string; max: number }) => unknown; default?: { Pool?: new (cfg: { connectionString: string; max: number }) => unknown } }; + const PoolConstructor = (pg.Pool ?? pg.default?.Pool ?? null); + if (PoolConstructor) { + pool = new PoolConstructor({ connectionString: url, max: 10 }) as typeof pool; + } + console.log('[resources] PostgreSQL pool created'); + return pool; + } catch { + console.warn('[resources] pg module not available, falling back to in-memory store'); + } + } + console.warn('[resources] DATABASE_URL not set, using in-memory store'); + return null; +} + +const memoryResources: Map = new Map(); +const memoryBookings: Map = new Map(); + +export async function listResources(tenantId: string, type?: string): Promise { + const db = await getPool(); + if (db) { + let query = 'SELECT * FROM resources WHERE tenant_id = $1 AND is_active = true'; + const params: string[] = [tenantId]; + if (type) { + query += ' AND type = $2'; + params.push(type); + } + query += ' ORDER BY name ASC'; + const result = await db.query(query, params); + return (result.rows as ResourceRow[]).map(rowToResource); + } + + let resources = Array.from(memoryResources.values()).filter(r => r.tenant_id === tenantId && r.is_active); + if (type) { + resources = resources.filter(r => r.type === type); + } + resources.sort((a, b) => a.name.localeCompare(b.name)); + return resources.map(rowToResource); +} + +export async function getResource(id: string): Promise { + const db = await getPool(); + if (db) { + const result = await db.query('SELECT * FROM resources WHERE id = $1', [id]); + if (result.rows.length === 0) return null; + return rowToResource(result.rows[0] as ResourceRow); + } + + const row = memoryResources.get(id); + return row ? rowToResource(row) : null; +} + +export async function createResource( + tenantId: string, + data: { name: string; type: Resource['type']; location?: string; capacity?: number; description?: string; contactEmail?: string; metadata?: Record } +): Promise { + const db = await getPool(); + if (db) { + const result = await db.query( + `INSERT INTO resources (id, tenant_id, name, type, location, capacity, description, contact_email, metadata) + VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [tenantId, data.name, data.type, data.location ?? null, data.capacity ?? null, data.description ?? null, data.contactEmail ?? null, JSON.stringify(data.metadata ?? {})] + ); + return rowToResource(result.rows[0] as ResourceRow); + } + + const id = generateUUID(); + const row: ResourceRow = { + id, + tenant_id: tenantId, + name: data.name, + type: data.type, + location: data.location ?? null, + capacity: data.capacity ?? null, + description: data.description ?? null, + contact_email: data.contactEmail ?? null, + is_active: true, + metadata: data.metadata ?? {}, + }; + memoryResources.set(id, row); + return rowToResource(row); +} + +export async function checkAvailability( + resourceId: string, + start: string, + end: string, +): Promise<{ available: boolean; conflicts: ResourceBooking[] }> { + const db = await getPool(); + if (db) { + const result = await db.query( + `SELECT * FROM resources_bookings + WHERE resource_id = $1 + AND start_time < $3::timestamptz + AND end_time > $2::timestamptz + ORDER BY start_time ASC`, + [resourceId, start, end], + ); + const conflicts = (result.rows as BookingRow[]).map(rowToBooking); + return { available: conflicts.length === 0, conflicts }; + } + + const conflicts = Array.from(memoryBookings.values()) + .filter(b => b.resource_id === resourceId && b.start_time < end && b.end_time > start) + .sort((a, b) => a.start_time.localeCompare(b.start_time)) + .map(rowToBooking); + return { available: conflicts.length === 0, conflicts }; +} + +export async function bookResource( + resourceId: string, + start: string, + end: string, + bookedBy: string, + eventId?: string, +): Promise { + const db = await getPool(); + if (db) { + const result = await db.query( + `INSERT INTO resources_bookings (id, resource_id, event_id, start_time, end_time, booked_by) + VALUES (gen_random_uuid(), $1, $2, $3::timestamptz, $4::timestamptz, $5) RETURNING *`, + [resourceId, eventId ?? null, start, end, bookedBy], + ); + return rowToBooking(result.rows[0] as BookingRow); + } + + const id = generateUUID(); + const row: BookingRow = { + id, + resource_id: resourceId, + event_id: eventId ?? null, + start_time: start, + end_time: end, + booked_by: bookedBy, + }; + memoryBookings.set(id, row); + return rowToBooking(row); +} + +export async function cancelBooking(bookingId: string): Promise { + const db = await getPool(); + if (db) { + await db.query('DELETE FROM resources_bookings WHERE id = $1', [bookingId]); + return; + } + + memoryBookings.delete(bookingId); +} + +export async function getBookingsForResource(resourceId: string): Promise { + const db = await getPool(); + if (db) { + const result = await db.query( + 'SELECT * FROM resources_bookings WHERE resource_id = $1 ORDER BY start_time ASC', + [resourceId], + ); + return (result.rows as BookingRow[]).map(rowToBooking); + } + + return Array.from(memoryBookings.values()) + .filter(b => b.resource_id === resourceId) + .sort((a, b) => a.start_time.localeCompare(b.start_time)) + .map(rowToBooking); +} + +export async function getBookingsForEvent(eventId: string): Promise { + const db = await getPool(); + if (db) { + const result = await db.query( + 'SELECT * FROM resources_bookings WHERE event_id = $1 ORDER BY start_time ASC', + [eventId], + ); + return (result.rows as BookingRow[]).map(rowToBooking); + } + + return Array.from(memoryBookings.values()) + .filter(b => b.event_id === eventId) + .sort((a, b) => a.start_time.localeCompare(b.start_time)) + .map(rowToBooking); +} + +export async function cancelBookingsForEvent(eventId: string): Promise { + const db = await getPool(); + if (db) { + await db.query('DELETE FROM resources_bookings WHERE event_id = $1', [eventId]); + return; + } + + for (const [id, booking] of memoryBookings) { + if (booking.event_id === eventId) { + memoryBookings.delete(id); + } + } +} + +export async function updateBookingEventId(bookingId: string, eventId: string): Promise { + const db = await getPool(); + if (db) { + await db.query('UPDATE resources_bookings SET event_id = $2 WHERE id = $1', [bookingId, eventId]); + return; + } + + const row = memoryBookings.get(bookingId); + if (row) { + row.event_id = eventId; + } +} diff --git a/lib/resources/schema.sql b/lib/resources/schema.sql new file mode 100644 index 00000000..22fa25f3 --- /dev/null +++ b/lib/resources/schema.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS resources ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('room', 'vehicle', 'equipment', 'other')), + location TEXT, + capacity INTEGER, + description TEXT, + contact_email TEXT, + is_active BOOLEAN DEFAULT true, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS resources_bookings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE, + event_id TEXT, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, + booked_by TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_resources_tenant ON resources(tenant_id); +CREATE INDEX IF NOT EXISTS idx_resources_type ON resources(type); +CREATE INDEX IF NOT EXISTS idx_bookings_resource_time ON resources_bookings(resource_id, start_time, end_time); +CREATE INDEX IF NOT EXISTS idx_bookings_time ON resources_bookings(start_time, end_time); diff --git a/locales/en/common.json b/locales/en/common.json index 66ef1338..f900e2e0 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3006,6 +3006,19 @@ "due_today": "Today", "due_tomorrow": "Tomorrow", "overdue": "Overdue" + }, + "resources": { + "title": "Resources", + "hide": "Hide resources", + "filter_all": "All", + "type_room": "Rooms", + "type_vehicle": "Vehicles", + "type_equipment": "Equipment", + "type_other": "Other", + "search_placeholder": "Search resources...", + "no_resources": "No resources available", + "remove": "Remove {name}", + "clear_all": "Clear all" } }, "sharing": { diff --git a/stores/resource-store.ts b/stores/resource-store.ts new file mode 100644 index 00000000..f30dca58 --- /dev/null +++ b/stores/resource-store.ts @@ -0,0 +1,154 @@ +import { create } from 'zustand'; +import { apiFetch } from '@/lib/browser-navigation'; +import type { Resource, ResourceBooking } from '@/lib/resources/client'; + +interface ResourceState { + resources: Resource[]; + selectedResources: Resource[]; + bookings: ResourceBooking[]; + isLoading: boolean; + bookingError: string | null; + + fetchResources: (type?: string) => Promise; + searchResources: (query: string) => Resource[]; + toggleResource: (resource: Resource) => void; + selectResource: (resource: Resource) => void; + deselectResource: (resourceId: string) => void; + clearSelection: () => void; + + fetchEventBookings: (eventId: string) => Promise; + bookSelectedResources: (start: string, end: string, eventId?: string) => Promise; + cancelBooking: (bookingId: string) => Promise; + cancelEventBookings: (eventId: string) => Promise; +} + +export const useResourceStore = create()((set, get) => ({ + resources: [], + selectedResources: [], + bookings: [], + isLoading: false, + bookingError: null, + + fetchResources: async (type?: string) => { + set({ isLoading: true }); + try { + const params = new URLSearchParams(); + if (type) params.set('type', type); + const res = await apiFetch(`/api/resources?${params.toString()}`); + if (!res.ok) throw new Error('Failed to fetch resources'); + const data = await res.json(); + set({ resources: data.resources, isLoading: false }); + } catch { + set({ isLoading: false }); + } + }, + + searchResources: (query: string) => { + const { resources } = get(); + if (!query.trim()) return resources; + const lower = query.toLowerCase(); + return resources.filter( + (r) => + r.name.toLowerCase().includes(lower) || + (r.location && r.location.toLowerCase().includes(lower)) || + (r.description && r.description.toLowerCase().includes(lower)) + ); + }, + + toggleResource: (resource: Resource) => { + const { selectedResources } = get(); + const exists = selectedResources.some((r) => r.id === resource.id); + if (exists) { + set({ selectedResources: selectedResources.filter((r) => r.id !== resource.id) }); + } else { + set({ selectedResources: [...selectedResources, resource] }); + } + }, + + selectResource: (resource: Resource) => { + const { selectedResources } = get(); + if (!selectedResources.some((r) => r.id === resource.id)) { + set({ selectedResources: [...selectedResources, resource] }); + } + }, + + deselectResource: (resourceId: string) => { + set({ selectedResources: get().selectedResources.filter((r) => r.id !== resourceId) }); + }, + + clearSelection: () => { + set({ selectedResources: [], bookingError: null }); + }, + + fetchEventBookings: async (eventId: string) => { + try { + const res = await apiFetch(`/api/resources?eventId=${encodeURIComponent(eventId)}`); + if (!res.ok) return; + const data = await res.json(); + set({ bookings: data.bookings || [] }); + } catch { + // silently fail + } + }, + + bookSelectedResources: async (start: string, end: string, eventId?: string) => { + set({ bookingError: null }); + const { selectedResources } = get(); + const bookedIds: string[] = []; + + for (const resource of selectedResources) { + try { + const res = await apiFetch(`/api/resources/${resource.id}/book`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ start, end, eventId }), + }); + if (!res.ok) { + const data = await res.json(); + set({ bookingError: `${resource.name}: ${data.error}` }); + continue; + } + const data = await res.json(); + bookedIds.push(data.booking.id); + } catch { + set({ bookingError: `Failed to book ${resource.name}` }); + } + } + + return bookedIds; + }, + + cancelBooking: async (bookingId: string) => { + const { bookings } = get(); + const booking = bookings.find((b) => b.id === bookingId); + if (!booking) return; + + try { + const res = await apiFetch( + `/api/resources/${booking.resourceId}/book/${bookingId}`, + { method: 'DELETE' } + ); + if (res.ok) { + set({ bookings: bookings.filter((b) => b.id !== bookingId) }); + } + } catch { + // silently fail + } + }, + + cancelEventBookings: async (_eventId: string) => { + const { bookings } = get(); + for (const booking of bookings) { + try { + const res = await apiFetch( + `/api/resources/${booking.resourceId}/book/${booking.id}`, + { method: 'DELETE' } + ); + if (res.ok) continue; + } catch { + // silently fail + } + } + set({ bookings: [] }); + }, +}));