Files
kiosk/web/src/routes/directory.tsx
2025-10-24 19:25:46 +02:00

277 lines
9.4 KiB
TypeScript

/**
* Directory Page
*
* Interactive company directory for facility tenants and visitors.
* Features smart filtering with dynamically generated alphabet buttons,
* presence-based status filtering, and auto-clearing unavailable selections.
* Companies are sorted with present companies first for easy sign-out access.
*/
/* React hooks */
import { useState, useEffect, useMemo, useCallback } from "react";
/* UI Components */
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
/* Kiosk Components */
import { CompanyCard } from "@/components/kiosk/company-card";
/* Types & Constants */
import { type Company } from "@/types/company";
import type { Room, FloorPlanWithRooms } from "@/types/floor-plan";
/* Context */
import { useKioskData } from "@/contexts/kiosk-data-context";
/* Services */
import { updateCompanyStatus } from "@/services/companies";
/* Constants */
import { LOADING_MESSAGES, ERROR_MESSAGES, EMPTY_MESSAGES, SECTION_HEADERS, FILTER_LABELS } from "@/constants/ui-text";
/* Filter state options: all companies, present only, or absent only */
type StatusFilter = "all" | "present" | "absent";
/**
* Directory Component
*
* Manages filtering state and renders company directory with smart features:
* - Dynamic alphabet generation (only shows letters that exist in data)
* - Smart letter availability (disabled when no matches for current filter)
* - Auto-clearing selections when they become invalid
* - Sorted sections (present first for easy sign-out, then absent)
* - Real-time updates via PocketBase subscriptions
*/
export function Directory() {
const { companies, floorPlans, loading, error } = useKioskData();
const [selectedLetter, setSelectedLetter] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [updatingCompanyId, setUpdatingCompanyId] = useState<string | null>(null);
/**
* Checks if a company matches the current status filter
* Extracted to avoid duplication - used in both availableLetters and filteredCompanies
*/
const matchesStatusFilter = useCallback(
(company: Company) => {
return (
statusFilter === "all" ||
(statusFilter === "present" && company.status === "present") ||
(statusFilter === "absent" && company.status === "absent")
);
},
[statusFilter],
);
/**
* Handles toggling company status between present and absent
* Updates via PocketBase API and real-time subscription auto-updates UI
*/
const handleStatusToggle = useCallback(async (company: Company): Promise<void> => {
const newStatus = company.status === "present" ? "absent" : "present";
setUpdatingCompanyId(company.id);
try {
await updateCompanyStatus(company.id, newStatus);
/* Real-time subscription will automatically update the UI */
} catch (error) {
console.error("Failed to update company status:", error);
/* TODO: Could add error toast notification here */
} finally {
setUpdatingCompanyId(null);
}
}, []);
/**
* Creates lookup map for company locations
* Prevents O(n*m) lookups when rendering company cards
*/
const companyLocationMap = useMemo(() => {
const map = new Map<string, { room: Room; floorPlan: FloorPlanWithRooms }>();
for (const floorPlan of floorPlans) {
for (const room of floorPlan.rooms) {
if (room.company) {
map.set(room.company, { room, floorPlan });
}
}
}
return map;
}, [floorPlans]);
/**
* Generates dynamic alphabet from actual company data
* Only displays letters that exist in the company list
*/
const alphabet = useMemo(() => {
const letters = new Set<string>();
companies.forEach((company) => {
const firstLetter = company.short_name.charAt(0).toUpperCase();
letters.add(firstLetter);
});
return Array.from(letters).sort();
}, [companies]);
/**
* Calculates which alphabet letters have companies for the current status filter
* Returns Set for O(1) lookup when rendering alphabet buttons
*/
const availableLetters = useMemo(() => {
const letters = new Set<string>();
companies.forEach((company) => {
if (matchesStatusFilter(company)) {
const firstLetter = company.short_name.charAt(0).toUpperCase();
letters.add(firstLetter);
}
});
return letters;
}, [companies, matchesStatusFilter]);
/**
* Auto-clear selected letter when it becomes unavailable
* Example: User has "M" selected, switches to "Prisutni" but no present
* companies start with "M" - auto-clears to show all present companies
*/
useEffect(() => {
if (selectedLetter && !availableLetters.has(selectedLetter)) {
setSelectedLetter(null);
}
}, [statusFilter, selectedLetter, availableLetters]);
/* Filter companies by both letter selection and status filter */
const filteredCompanies = useMemo(() => {
return companies.filter((company) => {
const matchesLetter = !selectedLetter || company.short_name.toUpperCase().startsWith(selectedLetter);
return matchesLetter && matchesStatusFilter(company);
});
}, [companies, selectedLetter, matchesStatusFilter]);
/* Separate and sort companies: present first (for easy sign-out), then absent */
const presentCompanies = filteredCompanies
.filter((c) => c.status === "present")
.sort((a, b) => a.short_name.localeCompare(b.short_name));
const absentCompanies = filteredCompanies
.filter((c) => c.status === "absent")
.sort((a, b) => a.short_name.localeCompare(b.short_name));
/* Loading state */
if (loading) {
return (
<main className="flex h-full items-center justify-center">
<p className="text-muted-foreground">{LOADING_MESSAGES.companies}</p>
</main>
);
}
/* Error state */
if (error) {
return (
<main className="flex h-full items-center justify-center">
<p className="text-destructive">{ERROR_MESSAGES.generic}</p>
</main>
);
}
return (
<main className="flex h-full flex-col gap-6">
{/* Filter controls: Status filter and dynamic alphabet navigation */}
<div className="flex items-center justify-center gap-8">
{/* Status filter: All, Present, or Absent companies */}
<div className="flex gap-2">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
onClick={() => setStatusFilter("all")}
className="h-12"
>
{FILTER_LABELS.all}
</Button>
<Button
variant={statusFilter === "present" ? "default" : "outline"}
onClick={() => setStatusFilter("present")}
className="h-12"
>
{FILTER_LABELS.present}
</Button>
<Button
variant={statusFilter === "absent" ? "default" : "outline"}
onClick={() => setStatusFilter("absent")}
className="h-12"
>
{FILTER_LABELS.absent}
</Button>
</div>
{/* Dynamic alphabet: Only shows letters that exist in company data */}
<div className="flex gap-2">
{alphabet.map((letter) => {
const isAvailable = availableLetters.has(letter);
const isSelected = selectedLetter === letter;
return (
<Button
key={letter}
variant={isSelected ? "default" : "outline"}
disabled={!isAvailable}
onClick={() => isAvailable && setSelectedLetter(isSelected ? null : letter)}
className={`h-14 w-14 p-0 ${!isAvailable ? "cursor-not-allowed opacity-40" : ""}`}
>
{letter}
</Button>
);
})}
</div>
</div>
{/* Present companies grid (shown first for easy tenant sign-out) */}
<div className="grid grid-cols-3 gap-4">
{presentCompanies.map((company) => (
<CompanyCard
key={company.id}
company={company}
location={companyLocationMap.get(company.id) || null}
isUpdating={updatingCompanyId === company.id}
onStatusToggle={handleStatusToggle}
/>
))}
</div>
{/* Visual separator between present and absent sections */}
{absentCompanies.length > 0 && presentCompanies.length > 0 && (
<div className="relative flex items-center py-2">
<Separator className="flex-1" />
<span className="text-muted-foreground px-4 text-sm">{SECTION_HEADERS.absentCompanies}</span>
<Separator className="flex-1" />
</div>
)}
{/* Absent companies grid (shown below separator) */}
{absentCompanies.length > 0 && (
<div className="grid grid-cols-3 gap-4">
{absentCompanies.map((company) => (
<CompanyCard
key={company.id}
company={company}
location={companyLocationMap.get(company.id) || null}
isUpdating={updatingCompanyId === company.id}
onStatusToggle={handleStatusToggle}
/>
))}
</div>
)}
{/* Empty state: Shown when no companies match the filter criteria */}
{filteredCompanies.length === 0 && (
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground">{EMPTY_MESSAGES.noCompanies}</p>
</div>
)}
</main>
);
}