Version 1.0 squash
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier -w **/*",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -18,6 +19,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.544.0",
|
||||
"pocketbase": "^0.26.2",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
@@ -26,6 +28,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
|
||||
42
web/pnpm-lock.yaml
generated
42
web/pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^0.544.0
|
||||
version: 0.544.0(react@19.1.1)
|
||||
pocketbase:
|
||||
specifier: ^0.26.2
|
||||
version: 0.26.2
|
||||
react:
|
||||
specifier: ^19.1.1
|
||||
version: 19.1.1
|
||||
@@ -54,6 +57,9 @@ importers:
|
||||
'@eslint/js':
|
||||
specifier: ^9.36.0
|
||||
version: 9.36.0
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.19
|
||||
version: 0.5.19(tailwindcss@4.1.13)
|
||||
'@types/node':
|
||||
specifier: ^24.5.2
|
||||
version: 24.5.2
|
||||
@@ -733,6 +739,11 @@ packages:
|
||||
resolution: {integrity: sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@tailwindcss/typography@0.5.19':
|
||||
resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==}
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
|
||||
|
||||
'@tailwindcss/vite@4.1.13':
|
||||
resolution: {integrity: sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ==}
|
||||
peerDependencies:
|
||||
@@ -924,6 +935,11 @@ packages:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
cssesc@3.0.0:
|
||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
csstype@3.1.3:
|
||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
|
||||
@@ -1322,6 +1338,13 @@ packages:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pocketbase@0.26.2:
|
||||
resolution: {integrity: sha512-WA8EOBc3QnSJh8rJ3iYoi9DmmPOMFIgVfAmIGux7wwruUEIzXgvrO4u0W2htfQjGIcyezJkdZOy5Xmh7SxAftw==}
|
||||
|
||||
postcss-selector-parser@6.0.10:
|
||||
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss@8.5.6:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -1625,6 +1648,9 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -2234,6 +2260,11 @@ snapshots:
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.13
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.1.13
|
||||
|
||||
'@tailwindcss/typography@0.5.19(tailwindcss@4.1.13)':
|
||||
dependencies:
|
||||
postcss-selector-parser: 6.0.10
|
||||
tailwindcss: 4.1.13
|
||||
|
||||
'@tailwindcss/vite@4.1.13(rolldown-vite@7.1.12(@types/node@24.5.2)(jiti@2.6.0))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.1.13
|
||||
@@ -2471,6 +2502,8 @@ snapshots:
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
csstype@3.1.3: {}
|
||||
|
||||
debug@4.4.3:
|
||||
@@ -2811,6 +2844,13 @@ snapshots:
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
|
||||
pocketbase@0.26.2: {}
|
||||
|
||||
postcss-selector-parser@6.0.10:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
postcss@8.5.6:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
@@ -3009,6 +3049,8 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
93
web/src/components/kiosk/company-card.tsx
Normal file
93
web/src/components/kiosk/company-card.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Company Card Component
|
||||
*
|
||||
* Displays a company card with logo, name, location, and status toggle.
|
||||
* Used in the directory listing with horizontal layout optimized for kiosks.
|
||||
*/
|
||||
|
||||
/* Icons */
|
||||
import { Building2, DoorOpen, CheckCircle, CircleX } from "lucide-react";
|
||||
|
||||
/* UI Components */
|
||||
import { Card, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
/* Types & Constants */
|
||||
import { type Company, statusVariants, statusBorderColors, statusLabels } from "@/types/company";
|
||||
import type { Room, FloorPlan } from "@/types/floor-plan";
|
||||
|
||||
/* Utility helpers */
|
||||
import { getFileUrl } from "@/lib/utils";
|
||||
|
||||
/* Constants */
|
||||
import { LOCATION_LABELS } from "@/constants/ui-text";
|
||||
|
||||
/**
|
||||
* Component props for CompanyCard
|
||||
*
|
||||
* @property company - Company data including logo, names, and status
|
||||
* @property location - Room and floor plan location data, null if not assigned
|
||||
* @property isUpdating - Whether the company status is currently being updated
|
||||
* @property onStatusToggle - Callback fired when user toggles company presence status
|
||||
*/
|
||||
interface CompanyCardProps {
|
||||
company: Company;
|
||||
location: { room: Room; floorPlan: FloorPlan } | null;
|
||||
isUpdating: boolean;
|
||||
onStatusToggle: (company: Company) => void;
|
||||
}
|
||||
|
||||
export function CompanyCard({ company, location, isUpdating, onStatusToggle }: CompanyCardProps) {
|
||||
const StatusIcon = company.status === "present" ? CheckCircle : CircleX;
|
||||
|
||||
return (
|
||||
<Card className={`relative ${statusBorderColors[company.status]}`}>
|
||||
<CardContent className="flex gap-4 p-4">
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={getFileUrl(company, company.logo)}
|
||||
alt={company.short_name}
|
||||
className="h-20 w-20 rounded-lg object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center gap-1">
|
||||
<CardTitle className="text-lg">{company.short_name}</CardTitle>
|
||||
<p className="text-muted-foreground line-clamp-1 text-sm">{company.full_name}</p>
|
||||
|
||||
{location && (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span>{location.floorPlan.floor}</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<DoorOpen className="h-4 w-4" />
|
||||
<span>
|
||||
{LOCATION_LABELS.room} {location.room.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onStatusToggle(company)}
|
||||
disabled={isUpdating}
|
||||
className="h-20 w-20 p-0"
|
||||
>
|
||||
<StatusIcon className={`size-12 ${isUpdating ? "opacity-50" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Badge variant={statusVariants[company.status]} className="absolute top-3 right-3">
|
||||
{statusLabels[company.status]}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
62
web/src/components/kiosk/floor-plan-markers.tsx
Normal file
62
web/src/components/kiosk/floor-plan-markers.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Floor Plan Markers Component
|
||||
*
|
||||
* Renders company logo markers positioned on floor plan SVG.
|
||||
* Uses percentage-based coordinates (0-100) for responsive positioning.
|
||||
*/
|
||||
|
||||
/* Types */
|
||||
import type { Room } from "@/types/floor-plan";
|
||||
|
||||
/* Utility helpers */
|
||||
import { getFileUrl } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Component props for FloorPlanMarkers
|
||||
*
|
||||
* @property rooms - Array of room objects with coordinates and company relations
|
||||
*/
|
||||
interface FloorPlanMarkersProps {
|
||||
rooms: Room[];
|
||||
}
|
||||
|
||||
export function FloorPlanMarkers({ rooms }: FloorPlanMarkersProps) {
|
||||
/**
|
||||
* Filter rooms that should display markers on the floor plan
|
||||
* Only show rooms that:
|
||||
* 1. Have map coordinates (map_x, map_y) configured in PocketBase
|
||||
* 2. Are currently occupied by a company (expand.company exists)
|
||||
* Empty rooms or rooms without coordinates are not displayed
|
||||
*/
|
||||
const visibleRooms = rooms.filter(
|
||||
(room) => room.map_x !== undefined && room.map_y !== undefined && room.expand?.company,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleRooms.map((room) => (
|
||||
<div
|
||||
key={room.id}
|
||||
className="absolute flex flex-col items-center gap-1"
|
||||
style={{
|
||||
left: `${room.map_x}%`,
|
||||
top: `${room.map_y}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-lg border-2 border-white bg-white shadow-lg">
|
||||
<img
|
||||
src={getFileUrl(room.expand!.company!, room.expand!.company!.logo)}
|
||||
alt={room.expand!.company!.short_name}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="rounded bg-white/90 px-2 py-0.5 text-xs font-medium shadow-sm">
|
||||
{room.expand!.company!.short_name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
157
web/src/components/kiosk/layout.tsx
Normal file
157
web/src/components/kiosk/layout.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Kiosk Layout Component
|
||||
*
|
||||
* Main layout wrapper for the business facility kiosk application.
|
||||
* Provides navigation sidebar, page title, live clock, and content area.
|
||||
*/
|
||||
|
||||
/* React hooks */
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/* Routing */
|
||||
import { Link, useLocation } from "wouter";
|
||||
|
||||
/* Icons */
|
||||
import { Megaphone, Building2, Map, Info } from "lucide-react";
|
||||
|
||||
/* UI Components */
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
/* Context */
|
||||
import { useKioskData } from "@/contexts/kiosk-data-context";
|
||||
|
||||
/* Utility helpers */
|
||||
import { isExpired } from "@/lib/utils";
|
||||
|
||||
/* Constants */
|
||||
import { PAGE_TITLES } from "@/constants/ui-text";
|
||||
|
||||
/* Navigation button configuration */
|
||||
interface NavButton {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/* Main navigation buttons displayed in the sidebar */
|
||||
const navButtons: NavButton[] = [
|
||||
{ icon: Megaphone, path: "/" },
|
||||
{ icon: Building2, path: "/directory" },
|
||||
{ icon: Map, path: "/navigation" },
|
||||
{ icon: Info, path: "/about" },
|
||||
];
|
||||
|
||||
/* Page title mapping for each route */
|
||||
const pageTitles: Record<string, string> = {
|
||||
"/": PAGE_TITLES.bulletin,
|
||||
"/directory": PAGE_TITLES.directory,
|
||||
"/navigation": PAGE_TITLES.navigation,
|
||||
"/about": PAGE_TITLES.about,
|
||||
};
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const [location] = useLocation();
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
|
||||
const { notices, companies } = useKioskData();
|
||||
|
||||
/**
|
||||
* Calculate badge counts for navigation buttons
|
||||
* Simple filter operations - no memoization needed as overhead exceeds benefit
|
||||
*/
|
||||
const activeNoticesCount = notices.filter((n) => !isExpired(n.expires_at)).length;
|
||||
const presentCompaniesCount = companies.filter((c) => c.status === "present").length;
|
||||
|
||||
/* Get badge count for navigation button based on path */
|
||||
const getBadgeCount = (path: string): number | undefined => {
|
||||
if (path === "/") return activeNoticesCount;
|
||||
if (path === "/directory") return presentCompaniesCount;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
/* Update time every minute */
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 60000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Formats time for display in header clock
|
||||
*
|
||||
* @param date - Date object to format
|
||||
* @returns Formatted time string (e.g., "9:30")
|
||||
*/
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats date for display in header clock
|
||||
*
|
||||
* @param date - Date object to format
|
||||
* @returns Formatted date string (e.g., "ponedeljak, oktobar 21")
|
||||
*/
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full">
|
||||
{/* Left sidebar navigation panel */}
|
||||
<div className="flex items-center justify-center p-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
{navButtons.map(({ icon: Icon, path }) => {
|
||||
const isActive = location === path;
|
||||
const badgeCount = getBadgeCount(path);
|
||||
|
||||
return (
|
||||
<Link key={path} href={path}>
|
||||
<Button variant={isActive ? "default" : "outline"} className="relative h-40 w-40">
|
||||
<Icon className="size-16" />
|
||||
{badgeCount !== undefined && badgeCount > 0 && (
|
||||
<Badge className="absolute -top-3 -right-3 h-12 min-w-12 px-3 text-lg font-bold">
|
||||
{badgeCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vertical divider between navigation and content */}
|
||||
<Separator orientation="vertical" className="h-full" />
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex flex-1 flex-col">
|
||||
{/* Header with page title and live clock */}
|
||||
<div className="flex items-center justify-between p-6 pb-4">
|
||||
<h1 className="text-4xl font-bold">{pageTitles[location] || "Page"}</h1>
|
||||
|
||||
<div className="text-muted-foreground flex flex-col items-end">
|
||||
<div className="text-2xl font-semibold">{formatTime(currentTime)}</div>
|
||||
<div className="text-base">{formatDate(currentTime)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator below header */}
|
||||
<Separator />
|
||||
|
||||
{/* Scrollable page content */}
|
||||
<div className="flex-1 overflow-auto p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
web/src/components/kiosk/notice-card.tsx
Normal file
62
web/src/components/kiosk/notice-card.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Notice Card Component
|
||||
*
|
||||
* Displays a notice/announcement with priority styling and expiry information.
|
||||
* Used on the bulletin board with automatic expiry detection and visual distinction.
|
||||
*/
|
||||
|
||||
/* UI Components */
|
||||
import { Card, CardHeader, CardTitle, CardAction, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
/* Types & Constants */
|
||||
import { type Notice, priorityVariants, priorityBorderColors, priorityLabels } from "@/types/notice";
|
||||
|
||||
/* Utility helpers */
|
||||
import { formatPostedDate, isExpired } from "@/lib/utils";
|
||||
|
||||
/* Constants */
|
||||
import { NOTICE_LABELS } from "@/constants/ui-text";
|
||||
|
||||
/**
|
||||
* Component props for NoticeCard
|
||||
*
|
||||
* @property notice - Notice data including title, message, priority, and expiry date
|
||||
*/
|
||||
interface NoticeCardProps {
|
||||
notice: Notice;
|
||||
}
|
||||
|
||||
export function NoticeCard({ notice }: NoticeCardProps) {
|
||||
const expired = isExpired(notice.expires_at);
|
||||
const expiryDateStr = new Date(notice.expires_at).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className={`${priorityBorderColors[notice.priority]} ${expired ? "opacity-60" : ""}`}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">{notice.title}</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant={priorityVariants[notice.priority]}>{priorityLabels[notice.priority]}</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="prose prose-base max-w-none" dangerouslySetInnerHTML={{ __html: notice.message }} />
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex-row items-center gap-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{NOTICE_LABELS.posted}: {formatPostedDate(notice.created)}
|
||||
</p>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<p className={`text-sm ${expired ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{expired ? NOTICE_LABELS.expired : NOTICE_LABELS.validUntil}: {expiryDateStr}
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
69
web/src/components/kiosk/room-info-panel.tsx
Normal file
69
web/src/components/kiosk/room-info-panel.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Room Info Panel Component
|
||||
*
|
||||
* Floating panel that displays selected room information.
|
||||
* Appears at bottom-center of navigation screen when a room is selected.
|
||||
*/
|
||||
|
||||
/* Icons */
|
||||
import { Building2 } from "lucide-react";
|
||||
|
||||
/* UI Components */
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
/* Types */
|
||||
import type { Room } from "@/types/floor-plan";
|
||||
|
||||
/* Constants */
|
||||
import { LOCATION_LABELS } from "@/constants/ui-text";
|
||||
|
||||
/**
|
||||
* Component props for RoomInfoPanel
|
||||
*
|
||||
* @property room - Room data including name, type, and occupying company
|
||||
* @property onClose - Callback fired when user closes the panel
|
||||
*/
|
||||
interface RoomInfoPanelProps {
|
||||
room: Room;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RoomInfoPanel({ room, onClose }: RoomInfoPanelProps) {
|
||||
return (
|
||||
<div className="absolute bottom-4 left-1/2 z-10 -translate-x-1/2">
|
||||
<Card className="border-primary shadow-lg">
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
<div className="flex flex-1 items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="text-muted-foreground h-5 w-5" />
|
||||
<span className="font-semibold">{room.name}</span>
|
||||
</div>
|
||||
|
||||
{room.type && (
|
||||
<>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span className="text-muted-foreground">{room.type}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{room.expand?.company && (
|
||||
<>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span className="font-medium">{room.expand.company.short_name}</span>
|
||||
<Badge>{LOCATION_LABELS.occupied}</Badge>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!room.expand?.company && <Badge variant="secondary">{LOCATION_LABELS.available}</Badge>}
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
{LOCATION_LABELS.close}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
web/src/components/ui/badge.tsx
Normal file
36
web/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
62
web/src/constants/ui-text.ts
Normal file
62
web/src/constants/ui-text.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* UI Text Constants
|
||||
*
|
||||
* Centralized location for all user-facing text strings in Bosnian.
|
||||
* Makes localization easier and reduces magic strings throughout the codebase.
|
||||
*/
|
||||
|
||||
/* Page titles displayed in header */
|
||||
export const PAGE_TITLES = {
|
||||
bulletin: "Obavještenja",
|
||||
directory: "Direktorij",
|
||||
navigation: "Navigacija",
|
||||
about: "Informacije",
|
||||
} as const;
|
||||
|
||||
/* Common loading messages */
|
||||
export const LOADING_MESSAGES = {
|
||||
notices: "Učitavanje obavještenja...",
|
||||
companies: "Učitavanje kompanija...",
|
||||
floorPlans: "Učitavanje planova spratova...",
|
||||
content: "Učitavanje sadržaja...",
|
||||
} as const;
|
||||
|
||||
/* Common error messages */
|
||||
export const ERROR_MESSAGES = {
|
||||
generic: "Došlo je do greške pri učitavanju.",
|
||||
} as const;
|
||||
|
||||
/* Empty state messages */
|
||||
export const EMPTY_MESSAGES = {
|
||||
noCompanies: "Nema kompanija koje odgovaraju kriterijumima pretraživanja.",
|
||||
noFloorPlan: "Plan sprata nije trenutno dostupan.",
|
||||
noContent: "Sadržaj trenutno nije dostupan.",
|
||||
} as const;
|
||||
|
||||
/* Section headers */
|
||||
export const SECTION_HEADERS = {
|
||||
expiredNotices: "Istekla obavještenja",
|
||||
absentCompanies: "Odsutne",
|
||||
} as const;
|
||||
|
||||
/* Filter button labels */
|
||||
export const FILTER_LABELS = {
|
||||
all: "Sve",
|
||||
present: "Prisutne",
|
||||
absent: "Odsutne",
|
||||
} as const;
|
||||
|
||||
/* Room and location labels */
|
||||
export const LOCATION_LABELS = {
|
||||
room: "Prostorija",
|
||||
occupied: "Zauzeto",
|
||||
available: "Dostupno",
|
||||
close: "Zatvori",
|
||||
} as const;
|
||||
|
||||
/* Notice card labels */
|
||||
export const NOTICE_LABELS = {
|
||||
posted: "Objavljeno",
|
||||
validUntil: "Važi do",
|
||||
expired: "Isteklo",
|
||||
} as const;
|
||||
113
web/src/contexts/kiosk-data-context.tsx
Normal file
113
web/src/contexts/kiosk-data-context.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Kiosk Data Context
|
||||
*
|
||||
* Centralized data provider for all kiosk data (notices, companies, floor plans).
|
||||
* Fetches all data once at app initialization and maintains real-time subscriptions.
|
||||
* All components consume from this single source of truth.
|
||||
*/
|
||||
|
||||
/* React hooks */
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from "react";
|
||||
|
||||
/* Types */
|
||||
import type { About } from "@/types/about";
|
||||
import type { Notice } from "@/types/notice";
|
||||
import type { Company } from "@/types/company";
|
||||
import type { FloorPlanWithRooms } from "@/types/floor-plan";
|
||||
|
||||
/* Services */
|
||||
import { getAbout, subscribeToAbout } from "@/services/about";
|
||||
import { getNotices, subscribeToNotices } from "@/services/notices";
|
||||
import { getCompanies, subscribeToCompanies } from "@/services/companies";
|
||||
import { getFloorPlans, subscribeToFloorPlans } from "@/services/floor-plans";
|
||||
|
||||
/* Context data structure */
|
||||
interface KioskData {
|
||||
about: About | null;
|
||||
notices: Notice[];
|
||||
companies: Company[];
|
||||
floorPlans: FloorPlanWithRooms[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/* Create context with default values */
|
||||
const KioskDataContext = createContext<KioskData>({
|
||||
about: null,
|
||||
notices: [],
|
||||
companies: [],
|
||||
floorPlans: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
/* Provider component */
|
||||
export function KioskDataProvider({ children }: { children: ReactNode }) {
|
||||
const [about, setAbout] = useState<About | null>(null);
|
||||
const [notices, setNotices] = useState<Notice[]>([]);
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [floorPlans, setFloorPlans] = useState<FloorPlanWithRooms[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
/* Fetch all data in parallel */
|
||||
Promise.all([getAbout(), getNotices(), getCompanies(), getFloorPlans()])
|
||||
.then(([fetchedAbout, fetchedNotices, fetchedCompanies, fetchedFloorPlans]) => {
|
||||
setAbout(fetchedAbout);
|
||||
setNotices(fetchedNotices);
|
||||
setCompanies(fetchedCompanies);
|
||||
setFloorPlans(fetchedFloorPlans);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
/* Set up real-time subscriptions */
|
||||
const unsubscribeAbout = subscribeToAbout((updatedAbout) => {
|
||||
setAbout(updatedAbout);
|
||||
});
|
||||
|
||||
const unsubscribeNotices = subscribeToNotices((updatedNotices) => {
|
||||
setNotices(updatedNotices);
|
||||
});
|
||||
|
||||
const unsubscribeCompanies = subscribeToCompanies((updatedCompanies) => {
|
||||
setCompanies(updatedCompanies);
|
||||
});
|
||||
|
||||
const unsubscribeFloorPlans = subscribeToFloorPlans((updatedFloorPlans) => {
|
||||
setFloorPlans(updatedFloorPlans);
|
||||
});
|
||||
|
||||
/* Cleanup all subscriptions on unmount */
|
||||
return () => {
|
||||
unsubscribeAbout();
|
||||
unsubscribeNotices();
|
||||
unsubscribeCompanies();
|
||||
unsubscribeFloorPlans();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<KioskDataContext.Provider value={{ about, notices, companies, floorPlans, loading, error }}>
|
||||
{children}
|
||||
</KioskDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to access kiosk data from context
|
||||
* Must be used within KioskDataProvider
|
||||
*/
|
||||
export function useKioskData() {
|
||||
const context = useContext(KioskDataContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useKioskData must be used within KioskDataProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
51
web/src/lib/pocketbase.ts
Normal file
51
web/src/lib/pocketbase.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* PocketBase Client Configuration
|
||||
*
|
||||
* Singleton PocketBase client instance with automatic environment detection.
|
||||
* Connects to local instance in development, production URL in production.
|
||||
*/
|
||||
|
||||
import PocketBase from "pocketbase";
|
||||
|
||||
/**
|
||||
* PocketBase URL configuration based on environment
|
||||
* Development: Local PocketBase instance (http://127.0.0.1:8090)
|
||||
* Production: Production PocketBase instance
|
||||
*
|
||||
* @returns PocketBase API base URL
|
||||
*/
|
||||
const getPocketBaseUrl = (): string => {
|
||||
const isDev = import.meta.env.MODE === "development";
|
||||
|
||||
if (isDev) {
|
||||
return "http://localhost:8090";
|
||||
}
|
||||
|
||||
/* Production URL - configure via build process or use relative URL */
|
||||
return import.meta.env.VITE_POCKETBASE_URL || "http://localhost:8090";
|
||||
};
|
||||
|
||||
/**
|
||||
* Singleton PocketBase client instance
|
||||
* Import this instance throughout the application for all PocketBase operations
|
||||
*/
|
||||
export const pb = new PocketBase(getPocketBaseUrl());
|
||||
|
||||
/**
|
||||
* Disable auto-cancellation to prevent request interruptions
|
||||
* By default, PocketBase cancels pending requests when new ones are made,
|
||||
* which can cause issues with concurrent requests and real-time subscriptions
|
||||
*/
|
||||
pb.autoCancellation(false);
|
||||
|
||||
/**
|
||||
* PocketBase collection names
|
||||
* Centralized collection name constants to avoid typos and enable refactoring
|
||||
*/
|
||||
export const Collections = {
|
||||
About: "about",
|
||||
Companies: "companies",
|
||||
Notices: "notices",
|
||||
FloorPlans: "floor_plans",
|
||||
Rooms: "rooms",
|
||||
} as const;
|
||||
65
web/src/lib/result.ts
Normal file
65
web/src/lib/result.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Result Utility
|
||||
*
|
||||
* Go-style error handling utilities for safer, more explicit error handling.
|
||||
* Provides Result type and tryCatch wrappers to eliminate try-catch boilerplate.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Go-style Result type for explicit error handling
|
||||
* Returns [data, null] on success or [null, error] on failure
|
||||
*
|
||||
* @example
|
||||
* const [data, error] = tryCatch(() => riskyOperation());
|
||||
* if (error) {
|
||||
* // Handle error
|
||||
* return;
|
||||
* }
|
||||
* // Use data safely
|
||||
*/
|
||||
export type Result<T, E = Error> = [T, null] | [null, E];
|
||||
|
||||
/**
|
||||
* Wraps a function in try-catch and returns Result tuple
|
||||
* Similar to Go's (value, error) pattern
|
||||
*
|
||||
* @param fn Function to execute
|
||||
* @returns Tuple of [result, null] or [null, error]
|
||||
*/
|
||||
export function tryCatch<T>(fn: () => T): Result<T> {
|
||||
try {
|
||||
const result = fn();
|
||||
return [result, null];
|
||||
} catch (error) {
|
||||
return [null, error instanceof Error ? error : new Error(String(error))];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version of tryCatch for promises
|
||||
*
|
||||
* @param fn Async function to execute
|
||||
* @returns Promise resolving to tuple of [result, null] or [null, error]
|
||||
*/
|
||||
export async function tryCatchAsync<T>(fn: () => Promise<T>): Promise<Result<T>> {
|
||||
try {
|
||||
const result = await fn();
|
||||
return [result, null];
|
||||
} catch (error) {
|
||||
return [null, error instanceof Error ? error : new Error(String(error))];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely executes a void function, ignoring any errors
|
||||
* Perfect for cleanup operations like unsubscribe where errors don't matter
|
||||
*
|
||||
* @param fn Function to execute safely
|
||||
*/
|
||||
export function safely(fn: () => void): void {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
/* Ignore errors during cleanup */
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,73 @@
|
||||
/**
|
||||
* Utility Functions
|
||||
*
|
||||
* Common helper functions for the application including:
|
||||
* - UI styling utilities (shadcn)
|
||||
* - Date formatting for notices
|
||||
* - PocketBase file URL resolution
|
||||
*/
|
||||
|
||||
/* Shadcn */
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import type { RecordModel } from "pocketbase";
|
||||
import { pb } from "./pocketbase";
|
||||
|
||||
/* Combines Tailwind classes with proper conflict resolution */
|
||||
const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
|
||||
export { cn };
|
||||
|
||||
/**
|
||||
* Formats PocketBase created timestamp to human-readable Bosnian format
|
||||
* Examples: "Danas, 9:30", "Juče, 15:15", "18. Okt, 10:45"
|
||||
*
|
||||
* @param dateStr ISO 8601 datetime string from PocketBase
|
||||
* @returns Formatted date string in Bosnian
|
||||
*/
|
||||
export function formatPostedDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
const timeStr = date.toLocaleTimeString("bs-BA", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
if (diffDays === 0) {
|
||||
return `Danas, ${timeStr}`;
|
||||
}
|
||||
|
||||
if (diffDays === 1) {
|
||||
return `Juče, ${timeStr}`;
|
||||
}
|
||||
|
||||
const dateStr2 = date.toLocaleDateString("bs-BA", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
return `${dateStr2}, ${timeStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a notice has expired
|
||||
*
|
||||
* @param expiresAt ISO 8601 datetime string
|
||||
* @returns true if the expiry date has passed, false otherwise
|
||||
*/
|
||||
export function isExpired(expiresAt: string): boolean {
|
||||
return new Date() > new Date(expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates PocketBase file URL for a record's file field
|
||||
*
|
||||
* @param record PocketBase record containing the file
|
||||
* @param filename Name of the file field
|
||||
* @returns Full URL to the file, or empty string if no file
|
||||
*/
|
||||
export function getFileUrl(record: RecordModel, filename: string): string {
|
||||
return filename ? pb.files.getURL(record, filename) : "";
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/* Wouter routing library */
|
||||
import { Router, Switch, Route } from "wouter";
|
||||
import { useHashLocation } from "wouter/use-hash-location";
|
||||
|
||||
const Routes = () => (
|
||||
<Router hook={useHashLocation}>
|
||||
<Switch>
|
||||
<Route path="/" component={() => <>Home</>} />
|
||||
<Route component={() => <>NotFound</>} />
|
||||
</Switch>
|
||||
</Router>
|
||||
);
|
||||
|
||||
export default Routes;
|
||||
50
web/src/routes/about.tsx
Normal file
50
web/src/routes/about.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* About Page
|
||||
*
|
||||
* Displays information about credits and sponsors for the facility.
|
||||
* Content is editable by staff through PocketBase rich text editor.
|
||||
* Updates appear in real-time across all kiosks.
|
||||
*/
|
||||
|
||||
/* Context */
|
||||
import { useKioskData } from "@/contexts/kiosk-data-context";
|
||||
|
||||
/* Constants */
|
||||
import { LOADING_MESSAGES, ERROR_MESSAGES, EMPTY_MESSAGES } from "@/constants/ui-text";
|
||||
|
||||
export function About() {
|
||||
const { about, loading, error } = useKioskData();
|
||||
|
||||
/* Loading state */
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">{LOADING_MESSAGES.content}</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>
|
||||
);
|
||||
}
|
||||
|
||||
/* Empty state - no about content created yet */
|
||||
if (!about) {
|
||||
return (
|
||||
<main className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">{EMPTY_MESSAGES.noContent}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex h-full flex-col overflow-auto">
|
||||
<div className="prose prose-lg max-w-none" dangerouslySetInnerHTML={{ __html: about.content }} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
99
web/src/routes/bulletin.tsx
Normal file
99
web/src/routes/bulletin.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
/* React hooks */
|
||||
import { useMemo } from "react";
|
||||
|
||||
/* UI Components */
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
/* Kiosk Components */
|
||||
import { NoticeCard } from "@/components/kiosk/notice-card";
|
||||
|
||||
/* Types & Constants */
|
||||
import { type Notice } from "@/types/notice";
|
||||
|
||||
/* Context */
|
||||
import { useKioskData } from "@/contexts/kiosk-data-context";
|
||||
|
||||
/* Utility helpers */
|
||||
import { isExpired } from "@/lib/utils";
|
||||
|
||||
/* Constants */
|
||||
import { LOADING_MESSAGES, ERROR_MESSAGES, SECTION_HEADERS } from "@/constants/ui-text";
|
||||
|
||||
/**
|
||||
* Bulletin Board Component
|
||||
*
|
||||
* Displays notices sorted by expiry status and post date.
|
||||
* Active notices appear first, followed by expired ones with visual distinction.
|
||||
* Uses real-time PocketBase subscriptions for instant updates across all kiosks.
|
||||
*/
|
||||
export function Bulletin() {
|
||||
const { notices, loading, error } = useKioskData();
|
||||
|
||||
/**
|
||||
* Sort and partition notices in a single pass
|
||||
* Active notices first (newest to oldest), then expired notices (newest to oldest)
|
||||
* Optimized to check expiry status only once per notice
|
||||
*/
|
||||
const { activeNotices, expiredNotices } = useMemo(() => {
|
||||
const active: Notice[] = [];
|
||||
const expired: Notice[] = [];
|
||||
|
||||
/* Partition notices by expiry status */
|
||||
for (const notice of notices) {
|
||||
if (isExpired(notice.expires_at)) {
|
||||
expired.push(notice);
|
||||
} else {
|
||||
active.push(notice);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sort each group by creation date (newest first) */
|
||||
const sortByNewest = (a: Notice, b: Notice) => new Date(b.created).getTime() - new Date(a.created).getTime();
|
||||
|
||||
active.sort(sortByNewest);
|
||||
expired.sort(sortByNewest);
|
||||
|
||||
return { activeNotices: active, expiredNotices: expired };
|
||||
}, [notices]);
|
||||
|
||||
/* Loading state */
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">{LOADING_MESSAGES.notices}</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-4">
|
||||
{/* Active notices section */}
|
||||
{activeNotices.map((notice) => (
|
||||
<NoticeCard key={notice.id} notice={notice} />
|
||||
))}
|
||||
|
||||
{/* Visual separator between active and expired notices */}
|
||||
{expiredNotices.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.expiredNotices}</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expired notices section */}
|
||||
{expiredNotices.map((notice) => (
|
||||
<NoticeCard key={notice.id} notice={notice} />
|
||||
))}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
312
web/src/routes/navigation.tsx
Normal file
312
web/src/routes/navigation.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Navigation Page
|
||||
*
|
||||
* Interactive building navigation with full-screen SVG floor plans.
|
||||
* Features compact inverted L-shape grid controls with annex filtering.
|
||||
*
|
||||
* Key Features:
|
||||
* - Inverted L-shape grid: [Floor0] [A] [B] on top, remaining floors vertically
|
||||
* - Annex buttons act as filters - clicking switches all floors to that annex
|
||||
* - Dynamic floor buttons - only shows floors available for selected annex
|
||||
* - Auto-selection when switching to annex without current floor
|
||||
* - Deep linking support for wayfinding (?floor=...&annex=...&room=...)
|
||||
* - Company logos positioned on floor plans via coordinates
|
||||
* - Coordinate helper (hold Shift) for easy admin setup
|
||||
*/
|
||||
|
||||
/* React hooks */
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
/* UI Components */
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/* Kiosk Components */
|
||||
import { RoomInfoPanel } from "@/components/kiosk/room-info-panel";
|
||||
import { FloorPlanMarkers } from "@/components/kiosk/floor-plan-markers";
|
||||
|
||||
/* Types */
|
||||
import type { Room } from "@/types/floor-plan";
|
||||
|
||||
/* Context */
|
||||
import { useKioskData } from "@/contexts/kiosk-data-context";
|
||||
|
||||
/* Utility helpers */
|
||||
import { getFileUrl } from "@/lib/utils";
|
||||
|
||||
/* Constants */
|
||||
import { LOADING_MESSAGES, ERROR_MESSAGES, EMPTY_MESSAGES } from "@/constants/ui-text";
|
||||
|
||||
/**
|
||||
* Navigation Component
|
||||
*
|
||||
* Displays interactive floor plans with inverted L-shape grid navigation.
|
||||
* Annexes filter which floor plans are shown, floors select the specific plan.
|
||||
*
|
||||
* Navigation Layout:
|
||||
* - Top row: First floor button + annex filter buttons (A, B, C...)
|
||||
* - Left column: Remaining floor buttons (dynamically filtered by selected annex)
|
||||
* - Clicking annex button switches all floors to that annex
|
||||
* - Clicking floor button selects that floor with current annex
|
||||
*
|
||||
* Features:
|
||||
* - Auto-selection when current floor doesn't exist in new annex
|
||||
* - Deep linking from Directory for wayfinding
|
||||
* - Company logos positioned on floor plans via coordinates
|
||||
* - Coordinate helper for admin setup (hold Shift)
|
||||
* - Real-time updates via PocketBase subscriptions
|
||||
*/
|
||||
export function Navigation() {
|
||||
const { floorPlans, loading, error } = useKioskData();
|
||||
|
||||
const [location] = useLocation();
|
||||
|
||||
/* Selected floor and annex state for inverted L-shape navigation */
|
||||
const [selectedFloor, setSelectedFloor] = useState<string>("");
|
||||
const [selectedAnnex, setSelectedAnnex] = useState<string>("");
|
||||
const [selectedRoom, setSelectedRoom] = useState<Room | null>(null);
|
||||
|
||||
/* Coordinate helper state */
|
||||
const [isShiftPressed, setIsShiftPressed] = useState(false);
|
||||
const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
/* Extract unique annexes from all floor plans */
|
||||
const annexes = useMemo(() => Array.from(new Set(floorPlans.map((fp) => fp.annex))).sort(), [floorPlans]);
|
||||
|
||||
/* Extract unique floors for the currently selected annex only */
|
||||
/* Preserve floor order based on first occurrence's sort_order */
|
||||
const floors = useMemo(() => {
|
||||
return Array.from(
|
||||
new Map(floorPlans.filter((fp) => fp.annex === selectedAnnex).map((fp) => [fp.floor, fp.sort_order])).entries(),
|
||||
)
|
||||
.sort(([, a], [, b]) => a - b)
|
||||
.map(([floor]) => floor);
|
||||
}, [floorPlans, selectedAnnex]);
|
||||
|
||||
/* Initialize with first floor plan when data loads */
|
||||
useEffect(() => {
|
||||
if (floorPlans.length > 0 && !selectedFloor && !selectedAnnex) {
|
||||
setSelectedFloor(floorPlans[0].floor);
|
||||
setSelectedAnnex(floorPlans[0].annex);
|
||||
}
|
||||
}, [floorPlans, selectedFloor, selectedAnnex]);
|
||||
|
||||
/* Get current floor plan by selected floor and annex */
|
||||
const currentFloorPlan = floorPlans.find((fp) => fp.floor === selectedFloor && fp.annex === selectedAnnex);
|
||||
|
||||
/* Auto-select valid floor when switching to annex with different available floors */
|
||||
useEffect(() => {
|
||||
if (!currentFloorPlan && selectedAnnex && floorPlans.length > 0) {
|
||||
/* Find first floor plan for the selected annex */
|
||||
const firstForAnnex = floorPlans.find((fp) => fp.annex === selectedAnnex);
|
||||
if (firstForAnnex) {
|
||||
setSelectedFloor(firstForAnnex.floor);
|
||||
}
|
||||
}
|
||||
}, [currentFloorPlan, selectedAnnex, floorPlans]);
|
||||
|
||||
/**
|
||||
* Handle deep linking from URL parameters
|
||||
* Enables wayfinding: Click company in Directory → auto-navigate to their
|
||||
* floor plan with room highlighted. Format: ?floor=Suteren&annex=A&room=S-01A
|
||||
*/
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.hash.split("?")[1]);
|
||||
const floor = params.get("floor");
|
||||
const annex = params.get("annex");
|
||||
const roomId = params.get("room");
|
||||
|
||||
/* Set floor and annex from URL parameters */
|
||||
if (floor && annex) {
|
||||
const floorPlan = floorPlans.find((fp) => fp.floor === floor && fp.annex === annex);
|
||||
if (floorPlan) {
|
||||
setSelectedFloor(floor);
|
||||
setSelectedAnnex(annex);
|
||||
|
||||
/* Find and select room if specified */
|
||||
if (roomId) {
|
||||
const room = floorPlan.rooms.find((r) => r.id === roomId);
|
||||
if (room) {
|
||||
setSelectedRoom(room);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [location, floorPlans]);
|
||||
|
||||
/**
|
||||
* Keyboard listener for coordinate helper (Shift key toggle)
|
||||
* Hold Shift to show x, y coordinates on floor plan
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Shift") {
|
||||
setIsShiftPressed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === "Shift") {
|
||||
setIsShiftPressed(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle mouse movement over floor plan to calculate coordinates
|
||||
* Converts pixel position to percentage (0-100) for easy PocketBase entry
|
||||
*/
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isShiftPressed) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
|
||||
setMousePosition({ x, y });
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear mouse position when mouse leaves floor plan container
|
||||
*/
|
||||
const handleMouseLeave = () => {
|
||||
setMousePosition(null);
|
||||
};
|
||||
|
||||
/* Loading state */
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">{LOADING_MESSAGES.floorPlans}</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">
|
||||
{/* Floor plan display with floating controls overlay */}
|
||||
{currentFloorPlan ? (
|
||||
<div className="relative flex h-full flex-col">
|
||||
{/**
|
||||
* Floating Control Overlay - Compact inverted L-shape grid
|
||||
* Top row: Floor0 + Annex filters [0] [A] [B]
|
||||
* Left column: Remaining floors [1] [2] [3]
|
||||
* Annexes act as filters - clicking changes which annex to show for all floors
|
||||
*/}
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-2">
|
||||
{/* Top row: First floor button + Annex filter buttons */}
|
||||
<div className="flex gap-2">
|
||||
{/* First floor button (corner position) */}
|
||||
{floors.length > 0 && (
|
||||
<Button
|
||||
variant={selectedFloor === floors[0] ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setSelectedFloor(floors[0]);
|
||||
setSelectedRoom(null);
|
||||
}}
|
||||
className="h-14 w-14 p-0 font-bold shadow-md"
|
||||
>
|
||||
{floors[0].charAt(0)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Annex filter buttons horizontally */}
|
||||
{annexes.map((annex) => (
|
||||
<Button
|
||||
key={annex}
|
||||
variant={selectedAnnex === annex ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setSelectedAnnex(annex);
|
||||
setSelectedRoom(null);
|
||||
}}
|
||||
className="h-14 w-14 p-0 font-bold shadow-md"
|
||||
>
|
||||
{annex}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Remaining floor buttons vertically (Floor 1, 2, 3...) */}
|
||||
{floors.slice(1).map((floor) => (
|
||||
<Button
|
||||
key={floor}
|
||||
variant={selectedFloor === floor ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setSelectedFloor(floor);
|
||||
setSelectedRoom(null);
|
||||
}}
|
||||
className="h-14 w-14 p-0 font-bold shadow-md"
|
||||
>
|
||||
{floor.charAt(0)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/**
|
||||
* SVG Floor Plan Container
|
||||
* Uses h-0 + flex-1 trick to force viewport fitting without scrolling
|
||||
* Image scales to fit available space with object-contain
|
||||
* Coordinate helper shows position when Shift is held
|
||||
*/}
|
||||
<div
|
||||
className="relative flex h-0 flex-1 items-center justify-center overflow-hidden rounded-lg border bg-white"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<img
|
||||
src={getFileUrl(currentFloorPlan, currentFloorPlan.svg_file)}
|
||||
alt={`${currentFloorPlan.floor} - ${currentFloorPlan.annex}`}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
onError={(e) => {
|
||||
console.error("Failed to load floor plan:", getFileUrl(currentFloorPlan, currentFloorPlan.svg_file));
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Company logos positioned on floor plan */}
|
||||
<FloorPlanMarkers rooms={currentFloorPlan.rooms} />
|
||||
|
||||
{/* Coordinate helper - Shows x,y when Shift is held */}
|
||||
{isShiftPressed && mousePosition && (
|
||||
<div
|
||||
className="pointer-events-none absolute rounded bg-black/80 px-3 py-2 font-mono text-sm text-white shadow-lg"
|
||||
style={{
|
||||
left: mousePosition.x + "%",
|
||||
top: mousePosition.y + "%",
|
||||
transform: "translate(10px, 10px)",
|
||||
}}
|
||||
>
|
||||
x: {mousePosition.x.toFixed(1)}%, y: {mousePosition.y.toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating room info panel - Appears at bottom-center when room selected */}
|
||||
{selectedRoom && <RoomInfoPanel room={selectedRoom} onClose={() => setSelectedRoom(null)} />}
|
||||
</div>
|
||||
) : (
|
||||
/* Empty state: Shown when no floor plan exists for current selection */
|
||||
<div className="bg-muted/30 flex flex-1 items-center justify-center rounded-lg border">
|
||||
<p className="text-muted-foreground">{EMPTY_MESSAGES.noFloorPlan}</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
40
web/src/routes/routes.tsx
Normal file
40
web/src/routes/routes.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Application Routes Configuration
|
||||
*
|
||||
* Defines the main routing structure for the kiosk application using Wouter.
|
||||
* Hash-based routing is used for compatibility with static hosting environments.
|
||||
*/
|
||||
|
||||
/* Routing library */
|
||||
import { Router, Switch, Route } from "wouter";
|
||||
import { useHashLocation } from "wouter/use-hash-location";
|
||||
|
||||
/* Context providers */
|
||||
import { KioskDataProvider } from "@/contexts/kiosk-data-context";
|
||||
|
||||
/* Layout wrapper */
|
||||
import { Layout } from "@/components/kiosk/layout";
|
||||
|
||||
/* Page components */
|
||||
import { Bulletin } from "@/routes/bulletin";
|
||||
import { Directory } from "@/routes/directory";
|
||||
import { Navigation } from "@/routes/navigation";
|
||||
import { About } from "@/routes/about";
|
||||
|
||||
const Routes = () => (
|
||||
<KioskDataProvider>
|
||||
<Router hook={useHashLocation}>
|
||||
<Layout>
|
||||
<Switch>
|
||||
<Route path="/" component={Bulletin} />
|
||||
<Route path="/directory" component={Directory} />
|
||||
<Route path="/navigation" component={Navigation} />
|
||||
<Route path="/about" component={About} />
|
||||
<Route component={() => <>Not Found</>} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
</Router>
|
||||
</KioskDataProvider>
|
||||
);
|
||||
|
||||
export default Routes;
|
||||
42
web/src/services/about.ts
Normal file
42
web/src/services/about.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* About Service
|
||||
*
|
||||
* Handles data operations for the about page content including fetching
|
||||
* and real-time subscriptions for staff edits.
|
||||
*
|
||||
* Returns data directly from PocketBase with no transformations.
|
||||
* Uses singleton pattern - only one about page record exists.
|
||||
*/
|
||||
|
||||
import { pb, Collections } from "@/lib/pocketbase";
|
||||
import type { About } from "@/types/about";
|
||||
import { safely } from "@/lib/result";
|
||||
|
||||
/**
|
||||
* Fetches the about page content from PocketBase
|
||||
* Returns the first (and only) about record
|
||||
*
|
||||
* @returns About page content or null if not created yet
|
||||
*/
|
||||
export async function getAbout(): Promise<About | null> {
|
||||
const records = await pb.collection(Collections.About).getFullList<About>();
|
||||
return records.length > 0 ? records[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to real-time about page updates
|
||||
* Callback is invoked whenever the about content is updated by staff
|
||||
*
|
||||
* @param callback Function to call when about content changes
|
||||
* @returns Unsubscribe function to clean up the subscription
|
||||
*/
|
||||
export function subscribeToAbout(callback: (about: About | null) => void): () => void {
|
||||
pb.collection(Collections.About).subscribe<About>("*", async () => {
|
||||
const about = await getAbout();
|
||||
callback(about);
|
||||
});
|
||||
|
||||
return () => {
|
||||
safely(() => pb.collection(Collections.About).unsubscribe("*"));
|
||||
};
|
||||
}
|
||||
53
web/src/services/companies.ts
Normal file
53
web/src/services/companies.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Companies Service
|
||||
*
|
||||
* Handles all data operations for companies including fetching,
|
||||
* updating presence status, and real-time subscriptions.
|
||||
*
|
||||
* Returns data directly from PocketBase with no transformations.
|
||||
* File URLs are resolved at display time using getFileUrl() helper.
|
||||
*/
|
||||
|
||||
import { pb, Collections } from "@/lib/pocketbase";
|
||||
import type { Company } from "@/types/company";
|
||||
import { safely } from "@/lib/result";
|
||||
|
||||
/**
|
||||
* Fetches all companies from PocketBase
|
||||
* Returns companies sorted alphabetically by short name
|
||||
*
|
||||
* @returns Array of all company records sorted by name
|
||||
*/
|
||||
export async function getCompanies(): Promise<Company[]> {
|
||||
return await pb.collection(Collections.Companies).getFullList<Company>({
|
||||
sort: "short_name",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a company's presence status (present/absent)
|
||||
* Used when tenants mark themselves as present or absent at the kiosk
|
||||
*/
|
||||
export async function updateCompanyStatus(companyId: string, status: "present" | "absent"): Promise<Company> {
|
||||
return await pb.collection(Collections.Companies).update<Company>(companyId, {
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to real-time company updates
|
||||
* Callback is invoked whenever any company record is created, updated, or deleted
|
||||
*
|
||||
* @param callback Function to call when companies change
|
||||
* @returns Unsubscribe function to clean up the subscription
|
||||
*/
|
||||
export function subscribeToCompanies(callback: (companies: Company[]) => void): () => void {
|
||||
pb.collection(Collections.Companies).subscribe<Company>("*", async () => {
|
||||
const companies = await getCompanies();
|
||||
callback(companies);
|
||||
});
|
||||
|
||||
return () => {
|
||||
safely(() => pb.collection(Collections.Companies).unsubscribe("*"));
|
||||
};
|
||||
}
|
||||
65
web/src/services/floor-plans.ts
Normal file
65
web/src/services/floor-plans.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Floor Plans Service
|
||||
*
|
||||
* Handles all data operations for floor plans including fetching
|
||||
* floor plan data with associated rooms and real-time subscriptions.
|
||||
*
|
||||
* Returns data directly from PocketBase with expand relations.
|
||||
* File URLs are resolved at display time using getFileUrl() helper.
|
||||
*/
|
||||
|
||||
import { pb, Collections } from "@/lib/pocketbase";
|
||||
import type { FloorPlan, FloorPlanWithRooms, Room } from "@/types/floor-plan";
|
||||
import { safely } from "@/lib/result";
|
||||
|
||||
/**
|
||||
* Fetches all floor plans with their associated rooms
|
||||
* Expands room -> company relations for complete data
|
||||
* Returns floor plans sorted by sort_order
|
||||
*
|
||||
* @returns Array of floor plans with nested rooms and company data
|
||||
*/
|
||||
export async function getFloorPlans(): Promise<FloorPlanWithRooms[]> {
|
||||
/* Fetch all floor plans */
|
||||
const floorPlans = await pb.collection(Collections.FloorPlans).getFullList<FloorPlan>({
|
||||
sort: "sort_order",
|
||||
});
|
||||
|
||||
/* Fetch all rooms with company relations expanded */
|
||||
const rooms = await pb.collection(Collections.Rooms).getFullList<Room>({
|
||||
expand: "company",
|
||||
});
|
||||
|
||||
/* Combine floor plans with their rooms */
|
||||
return floorPlans.map((floorPlan) => ({
|
||||
...floorPlan,
|
||||
rooms: rooms.filter((room) => room.floor_plan === floorPlan.id),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to real-time floor plan and room updates
|
||||
* Callback is invoked whenever floor plans or rooms change
|
||||
* Monitors both collections to ensure complete updates
|
||||
*
|
||||
* @param callback Function to call when floor plans change
|
||||
* @returns Unsubscribe function to clean up all subscriptions
|
||||
*/
|
||||
export function subscribeToFloorPlans(callback: (floorPlans: FloorPlanWithRooms[]) => void): () => void {
|
||||
pb.collection(Collections.FloorPlans).subscribe<FloorPlan>("*", async () => {
|
||||
const floorPlans = await getFloorPlans();
|
||||
callback(floorPlans);
|
||||
});
|
||||
|
||||
pb.collection(Collections.Rooms).subscribe<Room>("*", async () => {
|
||||
const floorPlans = await getFloorPlans();
|
||||
callback(floorPlans);
|
||||
});
|
||||
|
||||
return () => {
|
||||
safely(() => {
|
||||
pb.collection(Collections.FloorPlans).unsubscribe("*");
|
||||
pb.collection(Collections.Rooms).unsubscribe("*");
|
||||
});
|
||||
};
|
||||
}
|
||||
44
web/src/services/notices.ts
Normal file
44
web/src/services/notices.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Notices Service
|
||||
*
|
||||
* Handles all data operations for notices including fetching
|
||||
* and real-time subscriptions for bulletin board display.
|
||||
*
|
||||
* Returns data directly from PocketBase with no transformations.
|
||||
* Uses created field from RecordModel for posted date.
|
||||
*/
|
||||
|
||||
import { pb, Collections } from "@/lib/pocketbase";
|
||||
import type { Notice } from "@/types/notice";
|
||||
import { safely } from "@/lib/result";
|
||||
|
||||
/**
|
||||
* Fetches all notices from PocketBase
|
||||
* Returns notices sorted by created date (newest first)
|
||||
* Includes both active and expired notices for display
|
||||
*
|
||||
* @returns Array of all notice records sorted by creation date
|
||||
*/
|
||||
export async function getNotices(): Promise<Notice[]> {
|
||||
return await pb.collection(Collections.Notices).getFullList<Notice>({
|
||||
sort: "-created",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to real-time notice updates
|
||||
* Callback is invoked whenever any notice is created, updated, or deleted
|
||||
*
|
||||
* @param callback Function to call when notices change
|
||||
* @returns Unsubscribe function to clean up the subscription
|
||||
*/
|
||||
export function subscribeToNotices(callback: (notices: Notice[]) => void): () => void {
|
||||
pb.collection(Collections.Notices).subscribe<Notice>("*", async () => {
|
||||
const notices = await getNotices();
|
||||
callback(notices);
|
||||
});
|
||||
|
||||
return () => {
|
||||
safely(() => pb.collection(Collections.Notices).unsubscribe("*"));
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
|
||||
19
web/src/types/about.ts
Normal file
19
web/src/types/about.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* About type definition
|
||||
*
|
||||
* Contains the about page content structure.
|
||||
* Uses PocketBase RecordModel for automatic id, created, updated fields.
|
||||
*/
|
||||
|
||||
import type { RecordModel } from "pocketbase";
|
||||
|
||||
/**
|
||||
* About page content from PocketBase
|
||||
*
|
||||
* Extends RecordModel to get id, created, updated for free.
|
||||
* Content field contains HTML from PocketBase rich text editor.
|
||||
* Singleton pattern - only one about record exists.
|
||||
*/
|
||||
export interface About extends RecordModel {
|
||||
content: string /* HTML content from rich text editor */;
|
||||
}
|
||||
43
web/src/types/company.ts
Normal file
43
web/src/types/company.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Company type definitions and configuration
|
||||
*
|
||||
* Contains the core company data structure and associated styling constants
|
||||
* for presence-based visual representation in the directory listing.
|
||||
*
|
||||
* Uses PocketBase RecordModel for automatic id, created, updated fields.
|
||||
* File URLs are resolved at display time using getFileUrl() helper.
|
||||
*/
|
||||
|
||||
import type { RecordModel } from "pocketbase";
|
||||
|
||||
/**
|
||||
* Company from PocketBase
|
||||
*
|
||||
* Extends RecordModel to get id, created, updated for free.
|
||||
* All fields use snake_case to match database schema directly.
|
||||
* Logo field contains filename - use getFileUrl() to resolve URL.
|
||||
*/
|
||||
export interface Company extends RecordModel {
|
||||
logo: string /* PocketBase file field name */;
|
||||
short_name: string /* Display name (e.g., "TechCorp") */;
|
||||
full_name: string /* Full legal name (e.g., "TechCorp International d.o.o.") */;
|
||||
status: "present" | "absent";
|
||||
}
|
||||
|
||||
/* Badge variant mapping for each status */
|
||||
export const statusVariants = {
|
||||
present: "default",
|
||||
absent: "secondary",
|
||||
} as const;
|
||||
|
||||
/* Border color classes for each status */
|
||||
export const statusBorderColors = {
|
||||
present: "border-primary",
|
||||
absent: "border-border",
|
||||
} as const;
|
||||
|
||||
/* Localized labels (Bosnian) for each status */
|
||||
export const statusLabels = {
|
||||
present: "Prisutne",
|
||||
absent: "Odsutne",
|
||||
} as const;
|
||||
53
web/src/types/floor-plan.ts
Normal file
53
web/src/types/floor-plan.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Floor Plan type definitions and configuration
|
||||
*
|
||||
* Contains data structures for interactive building navigation,
|
||||
* including floor plans, rooms, and their associated metadata.
|
||||
*
|
||||
* Uses PocketBase RecordModel for automatic id, created, updated fields.
|
||||
* File URLs are resolved at display time using getFileUrl() helper.
|
||||
*/
|
||||
|
||||
import type { RecordModel } from "pocketbase";
|
||||
import type { Company } from "./company";
|
||||
|
||||
/**
|
||||
* Floor Plan from PocketBase
|
||||
*
|
||||
* Extends RecordModel to get id, created, updated for free.
|
||||
* All fields use snake_case to match database schema directly.
|
||||
* SVG file field contains filename - use getFileUrl() to resolve URL.
|
||||
*/
|
||||
export interface FloorPlan extends RecordModel {
|
||||
floor: string /* Floor level name (e.g., "Suteren", "Prizemlje") */;
|
||||
annex: string /* Building annex name (e.g., "A", "B") */;
|
||||
svg_file: string /* PocketBase file field name */;
|
||||
sort_order: number /* Display order (lower = shown first) */;
|
||||
}
|
||||
|
||||
/**
|
||||
* Room from PocketBase
|
||||
*
|
||||
* Extends RecordModel to get id, created, updated for free.
|
||||
* All fields use snake_case to match database schema directly.
|
||||
* Company relation is expanded via PocketBase expand parameter.
|
||||
*/
|
||||
export interface Room extends RecordModel {
|
||||
name: string /* Room identifier (e.g., "2-05A", "S-01A") */;
|
||||
type?: string /* Room type (e.g., "Kancelarija", "WC", "Kuhinja") */;
|
||||
floor_plan: string /* Relation to floor_plan */;
|
||||
company?: string /* Relation to company (optional) */;
|
||||
map_x?: number /* Horizontal position on floor plan as percentage (0-100) */;
|
||||
map_y?: number /* Vertical position on floor plan as percentage (0-100) */;
|
||||
expand?: {
|
||||
company?: Company /* Expanded company relation */;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Floor Plan with associated rooms
|
||||
* Combines floor plan metadata with room data for navigation
|
||||
*/
|
||||
export interface FloorPlanWithRooms extends FloorPlan {
|
||||
rooms: Room[];
|
||||
}
|
||||
46
web/src/types/notice.ts
Normal file
46
web/src/types/notice.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Notice type definitions and configuration
|
||||
*
|
||||
* Contains the core notice data structure and associated styling constants
|
||||
* for priority-based visual representation in the kiosk bulletin board.
|
||||
*
|
||||
* Uses PocketBase RecordModel for automatic id, created, updated fields.
|
||||
* Date formatting is handled by utility functions in lib/utils.ts.
|
||||
*/
|
||||
|
||||
import type { RecordModel } from "pocketbase";
|
||||
|
||||
/**
|
||||
* Notice from PocketBase
|
||||
*
|
||||
* Extends RecordModel to get id, created, updated for free.
|
||||
* Uses created field for posted date (no manual posted_at needed).
|
||||
* All fields use snake_case to match database schema directly.
|
||||
*/
|
||||
export interface Notice extends RecordModel {
|
||||
title: string;
|
||||
message: string;
|
||||
expires_at: string /* ISO 8601 datetime string */;
|
||||
priority: "urgent" | "normal" | "info";
|
||||
}
|
||||
|
||||
/* Badge variant mapping for each priority level */
|
||||
export const priorityVariants = {
|
||||
urgent: "destructive",
|
||||
normal: "secondary",
|
||||
info: "default",
|
||||
} as const;
|
||||
|
||||
/* Border color classes for each priority level */
|
||||
export const priorityBorderColors = {
|
||||
urgent: "border-destructive",
|
||||
normal: "border-border",
|
||||
info: "border-primary",
|
||||
} as const;
|
||||
|
||||
/* Localized labels (Bosnian) for each priority level */
|
||||
export const priorityLabels = {
|
||||
urgent: "Hitno",
|
||||
normal: "Opšte",
|
||||
info: "Informativno",
|
||||
} as const;
|
||||
@@ -6,7 +6,7 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
/* Route-related */
|
||||
import Routes from "@/routes/Routes";
|
||||
import Routes from "@/routes/routes";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
||||
Reference in New Issue
Block a user