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,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