- 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
244 lines
8.1 KiB
TypeScript
244 lines
8.1 KiB
TypeScript
"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>
|
|
);
|
|
}
|