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:
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|||||||
import { useTranslations, useLocale } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||||
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
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 type { ConflictWarning } from "@/lib/plugin-types";
|
||||||
import { RecipientPopover } from "@/components/email/recipient-popover";
|
import { RecipientPopover } from "@/components/email/recipient-popover";
|
||||||
import { useProTabStore } from "@/stores/pro-tab-store";
|
import { useProTabStore } from "@/stores/pro-tab-store";
|
||||||
|
import { ResourcePicker } from "./resource-picker";
|
||||||
|
import { useResourceStore } from "@/stores/resource-store";
|
||||||
|
|
||||||
export interface PendingEventPreview {
|
export interface PendingEventPreview {
|
||||||
start: Date;
|
start: Date;
|
||||||
@@ -383,6 +385,9 @@ export function EventModal({
|
|||||||
});
|
});
|
||||||
const openComposeTab = useProTabStore((s) => s.openComposeTab);
|
const openComposeTab = useProTabStore((s) => s.openComposeTab);
|
||||||
|
|
||||||
|
const resourceStore = useResourceStore();
|
||||||
|
const [showResources, setShowResources] = useState(false);
|
||||||
|
|
||||||
// Plugin transform: collect conflict warnings for the current event form.
|
// Plugin transform: collect conflict warnings for the current event form.
|
||||||
// Re-runs (debounced) whenever fields that affect scheduling change.
|
// Re-runs (debounced) whenever fields that affect scheduling change.
|
||||||
const [pluginConflictWarnings, setPluginConflictWarnings] = useState<ConflictWarning[]>([]);
|
const [pluginConflictWarnings, setPluginConflictWarnings] = useState<ConflictWarning[]>([]);
|
||||||
@@ -408,6 +413,13 @@ export function EventModal({
|
|||||||
return () => { cancelled = true; clearTimeout(t); };
|
return () => { cancelled = true; clearTimeout(t); };
|
||||||
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId]);
|
}, [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
|
// Report live preview to parent for grid outline
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onPreviewChange || isEdit) return;
|
if (!onPreviewChange || isEdit) return;
|
||||||
@@ -633,10 +645,20 @@ export function EventModal({
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
await onSave(data, shouldSendScheduling);
|
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 {
|
} finally {
|
||||||
setIsSaving(false);
|
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']) => {
|
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||||
if (!event || !userParticipantId || !onRsvp) return;
|
if (!event || !userParticipantId || !onRsvp) return;
|
||||||
@@ -1054,6 +1076,26 @@ export function EventModal({
|
|||||||
</div>
|
</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 */}
|
{/* Timezone */}
|
||||||
{!event.showWithoutTime && event.timeZone && (
|
{!event.showWithoutTime && event.timeZone && (
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
@@ -1111,7 +1153,7 @@ export function EventModal({
|
|||||||
showDeleteConfirm ? (
|
showDeleteConfirm ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-destructive">{t("form.delete_confirm")}</span>
|
<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")}
|
{t("events.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||||
@@ -1310,6 +1352,27 @@ export function EventModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -1573,7 +1636,7 @@ export function EventModal({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
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"
|
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
|
||||||
>
|
>
|
||||||
{t("events.delete")}
|
{t("events.delete")}
|
||||||
|
|||||||
@@ -8,11 +8,18 @@ import { useAuthStore } from "@/stores/auth-store";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { fetchFreeBusy, type FreeBusySlot, isWorkingHour as isWorkingHourFn } from "@/lib/calendar-freebusy";
|
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 {
|
export interface FreeBusyViewProps {
|
||||||
participants: { name?: string; email: string }[];
|
participants: { name?: string; email: string }[];
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
onTimeSelect?: (start: Date, end: Date) => void;
|
onTimeSelect?: (start: Date, end: Date) => void;
|
||||||
|
resources?: ResourceFreeBusyEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SLOT_MINUTES = 30;
|
const SLOT_MINUTES = 30;
|
||||||
@@ -78,6 +85,7 @@ export function FreeBusyView({
|
|||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
onTimeSelect,
|
onTimeSelect,
|
||||||
|
resources = [],
|
||||||
}: FreeBusyViewProps) {
|
}: FreeBusyViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const client = useAuthStore((s) => s.client);
|
const client = useAuthStore((s) => s.client);
|
||||||
@@ -255,6 +263,54 @@ export function FreeBusyView({
|
|||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, ResourceRow> = new Map();
|
||||||
|
const memoryBookings: Map<string, BookingRow> = new Map();
|
||||||
|
|
||||||
|
export async function listResources(tenantId: string, type?: string): Promise<Resource[]> {
|
||||||
|
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<Resource | null> {
|
||||||
|
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<string, unknown> }
|
||||||
|
): Promise<Resource> {
|
||||||
|
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<ResourceBooking> {
|
||||||
|
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<void> {
|
||||||
|
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<ResourceBooking[]> {
|
||||||
|
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<ResourceBooking[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -3006,6 +3006,19 @@
|
|||||||
"due_today": "Today",
|
"due_today": "Today",
|
||||||
"due_tomorrow": "Tomorrow",
|
"due_tomorrow": "Tomorrow",
|
||||||
"overdue": "Overdue"
|
"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": {
|
"sharing": {
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
searchResources: (query: string) => Resource[];
|
||||||
|
toggleResource: (resource: Resource) => void;
|
||||||
|
selectResource: (resource: Resource) => void;
|
||||||
|
deselectResource: (resourceId: string) => void;
|
||||||
|
clearSelection: () => void;
|
||||||
|
|
||||||
|
fetchEventBookings: (eventId: string) => Promise<void>;
|
||||||
|
bookSelectedResources: (start: string, end: string, eventId?: string) => Promise<string[]>;
|
||||||
|
cancelBooking: (bookingId: string) => Promise<void>;
|
||||||
|
cancelEventBookings: (eventId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useResourceStore = create<ResourceState>()((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: [] });
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user