Version 1.0 squash
This commit is contained in:
276
web/src/routes/directory.tsx
Normal file
276
web/src/routes/directory.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user