Files
SRCmail/components/calendar/resource-picker.tsx
T
Bernd Rodler b98ab59f0d fix: Phase 2 QA — all 25 remaining HIGH/MEDIUM/LOW issues
HIGH fixes (7):
- H1: VNCdirectory admin i18n — 30+ translation keys added
- H2: handleSave try/catch with error toast
- H3: Free/busy accountId scoping
- H4: cancelEventBookings filter by eventId
- H5: Resource picker static apiFetch import
- H6: Sharing-store toast messages via lastMessage state
- H7: roleLabel for all resource types

MEDIUM fixes (11):
- M1: identitySignatureMap cleanup on delete
- M2: Now-line relative positioning
- M3: Radial menu disabled item keyboard nav
- M4: Radial menu stable event listener via refs
- M5: cancelBooking error on missing booking
- M6: PasswordRow isMasked state flag
- M7: Extract shared rights into lib/sharing-rights.ts
- M8: VNCtalk client server-side guard
- M9: Collabora configManager instead of process.env
- M10: CONFIG_ENV_MAP VNCdirectory fields
- M11: SENSITIVE_CONFIG_KEYS field name unification

LOW fixes (7):
- L1-L3: Unused imports removed
- L4: aria-labels on close, clear, search, spinner
- L5-L7: Comments for intentional patterns, null guard
2026-08-07 14:21:07 +02:00

245 lines
8.2 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 { apiFetch } from "@/lib/browser-navigation";
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 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"
aria-label="Search resources"
/>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8" role="status" aria-label="Loading resources">
<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>
);
}