diff --git a/schema/pb_schema.json b/schema/pb_schema.json
new file mode 100644
index 0000000..6aa5c71
--- /dev/null
+++ b/schema/pb_schema.json
@@ -0,0 +1,508 @@
+[
+ {
+ "id": "pbc_3085411453",
+ "listRule": "",
+ "viewRule": "",
+ "createRule": null,
+ "updateRule": null,
+ "deleteRule": null,
+ "name": "rooms",
+ "type": "base",
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{15}",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 15,
+ "min": 15,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text1579384326",
+ "max": 0,
+ "min": 0,
+ "name": "name",
+ "pattern": "",
+ "presentable": true,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text2363381545",
+ "max": 0,
+ "min": 0,
+ "name": "type",
+ "pattern": "",
+ "presentable": true,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "cascadeDelete": true,
+ "collectionId": "pbc_397918867",
+ "hidden": false,
+ "id": "relation4054224987",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "floor_plan",
+ "presentable": true,
+ "required": true,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "cascadeDelete": true,
+ "collectionId": "pbc_3866053794",
+ "hidden": false,
+ "id": "relation1337919823",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "company",
+ "presentable": true,
+ "required": true,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "hidden": false,
+ "id": "number320209986",
+ "max": 100,
+ "min": 0,
+ "name": "map_x",
+ "onlyInt": false,
+ "presentable": true,
+ "required": true,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "number1678849236",
+ "max": 100,
+ "min": 0,
+ "name": "map_y",
+ "onlyInt": false,
+ "presentable": true,
+ "required": true,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "autodate2990389176",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ },
+ {
+ "hidden": false,
+ "id": "autodate3332085495",
+ "name": "updated",
+ "onCreate": true,
+ "onUpdate": true,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ],
+ "indexes": [
+ "CREATE INDEX `idx_zsTeabn0ng` ON `rooms` (`floor_plan`)",
+ "CREATE INDEX `idx_huUSmuvpCm` ON `rooms` (`company`)"
+ ],
+ "system": false
+ },
+ {
+ "id": "pbc_3866499052",
+ "listRule": "",
+ "viewRule": "",
+ "createRule": null,
+ "updateRule": null,
+ "deleteRule": null,
+ "name": "notices",
+ "type": "base",
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{15}",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 15,
+ "min": 15,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text724990059",
+ "max": 0,
+ "min": 0,
+ "name": "title",
+ "pattern": "",
+ "presentable": true,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "convertURLs": false,
+ "hidden": false,
+ "id": "editor3065852031",
+ "maxSize": 0,
+ "name": "message",
+ "presentable": true,
+ "required": true,
+ "system": false,
+ "type": "editor"
+ },
+ {
+ "hidden": false,
+ "id": "select1655102503",
+ "maxSelect": 1,
+ "name": "priority",
+ "presentable": true,
+ "required": true,
+ "system": false,
+ "type": "select",
+ "values": [
+ "urgent",
+ "normal",
+ "info"
+ ]
+ },
+ {
+ "hidden": false,
+ "id": "date261981154",
+ "max": "",
+ "min": "",
+ "name": "expires_at",
+ "presentable": true,
+ "required": true,
+ "system": false,
+ "type": "date"
+ },
+ {
+ "hidden": false,
+ "id": "autodate2990389176",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ },
+ {
+ "hidden": false,
+ "id": "autodate3332085495",
+ "name": "updated",
+ "onCreate": true,
+ "onUpdate": true,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ],
+ "indexes": [
+ "CREATE INDEX `idx_KYJQu2mFEa` ON `notices` (`created`) DESC",
+ "CREATE INDEX `idx_MWJUrYaDZ8` ON `notices` (`expires_at`)"
+ ],
+ "system": false
+ },
+ {
+ "id": "pbc_397918867",
+ "listRule": "",
+ "viewRule": "",
+ "createRule": null,
+ "updateRule": null,
+ "deleteRule": null,
+ "name": "floor_plans",
+ "type": "base",
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{15}",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 15,
+ "min": 15,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text3192247854",
+ "max": 0,
+ "min": 0,
+ "name": "floor",
+ "pattern": "",
+ "presentable": true,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text3180636438",
+ "max": 0,
+ "min": 0,
+ "name": "annex",
+ "pattern": "",
+ "presentable": true,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "file3391890175",
+ "maxSelect": 1,
+ "maxSize": 0,
+ "mimeTypes": [
+ "image/svg+xml"
+ ],
+ "name": "svg_file",
+ "presentable": true,
+ "protected": false,
+ "required": true,
+ "system": false,
+ "thumbs": [],
+ "type": "file"
+ },
+ {
+ "hidden": false,
+ "id": "number1169138922",
+ "max": null,
+ "min": 0,
+ "name": "sort_order",
+ "onlyInt": true,
+ "presentable": true,
+ "required": true,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "autodate2990389176",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ },
+ {
+ "hidden": false,
+ "id": "autodate3332085495",
+ "name": "updated",
+ "onCreate": true,
+ "onUpdate": true,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ],
+ "indexes": [
+ "CREATE UNIQUE INDEX `idx_2wui8ye9ED` ON `floor_plans` (\n `floor`,\n `annex`\n)",
+ "CREATE INDEX `idx_mXHeuKQiOj` ON `floor_plans` (`sort_order`)"
+ ],
+ "system": false
+ },
+ {
+ "id": "pbc_3866053794",
+ "listRule": "",
+ "viewRule": "",
+ "createRule": null,
+ "updateRule": "@request.body.logo:isset = false &&\n\n@request.body.short_name:isset = false &&\n@request.body.full_name:isset = false &&\n@request.body.industry:isset = false &&\n@request.body.status:isset = true &&\n\n@request.body.floor:isset = false &&\n@request.body.room:isset = false",
+ "deleteRule": null,
+ "name": "companies",
+ "type": "base",
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{15}",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 15,
+ "min": 15,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "file3834550803",
+ "maxSelect": 1,
+ "maxSize": 0,
+ "mimeTypes": [
+ "image/png",
+ "image/jpeg"
+ ],
+ "name": "logo",
+ "presentable": true,
+ "protected": false,
+ "required": false,
+ "system": false,
+ "thumbs": [],
+ "type": "file"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text1055174803",
+ "max": 0,
+ "min": 0,
+ "name": "short_name",
+ "pattern": "",
+ "presentable": true,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text3687080900",
+ "max": 0,
+ "min": 0,
+ "name": "full_name",
+ "pattern": "",
+ "presentable": true,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "select2063623452",
+ "maxSelect": 1,
+ "name": "status",
+ "presentable": false,
+ "required": true,
+ "system": false,
+ "type": "select",
+ "values": [
+ "present",
+ "absent"
+ ]
+ },
+ {
+ "hidden": false,
+ "id": "autodate2990389176",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ },
+ {
+ "hidden": false,
+ "id": "autodate3332085495",
+ "name": "updated",
+ "onCreate": true,
+ "onUpdate": true,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ],
+ "indexes": [
+ "CREATE UNIQUE INDEX `idx_YcZEbYalR7` ON `companies` (`short_name`)"
+ ],
+ "system": false
+ },
+ {
+ "id": "pbc_2125634568",
+ "listRule": "",
+ "viewRule": "",
+ "createRule": null,
+ "updateRule": null,
+ "deleteRule": null,
+ "name": "about",
+ "type": "base",
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{15}",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 15,
+ "min": 15,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "convertURLs": false,
+ "hidden": false,
+ "id": "editor4274335913",
+ "maxSize": 0,
+ "name": "content",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "editor"
+ },
+ {
+ "hidden": false,
+ "id": "autodate2990389176",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ },
+ {
+ "hidden": false,
+ "id": "autodate3332085495",
+ "name": "updated",
+ "onCreate": true,
+ "onUpdate": true,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ],
+ "indexes": [],
+ "system": false
+ }
+]
\ No newline at end of file
diff --git a/web/package.json b/web/package.json
index e28d8ed..985a132 100644
--- a/web/package.json
+++ b/web/package.json
@@ -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",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 13dd92b..b48083c 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -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
diff --git a/web/src/components/kiosk/company-card.tsx b/web/src/components/kiosk/company-card.tsx
new file mode 100644
index 0000000..5b341e4
--- /dev/null
+++ b/web/src/components/kiosk/company-card.tsx
@@ -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 (
+
+
+
+
+
+
+
+
{company.short_name}
+
{company.full_name}
+
+ {location && (
+
+
+
+ {location.floorPlan.floor}
+
+
•
+
+
+
+ {LOCATION_LABELS.room} {location.room.name}
+
+
+
+ )}
+
+
+
+ onStatusToggle(company)}
+ disabled={isUpdating}
+ className="h-20 w-20 p-0"
+ >
+
+
+
+
+
+ {statusLabels[company.status]}
+
+
+
+ );
+}
diff --git a/web/src/components/kiosk/floor-plan-markers.tsx b/web/src/components/kiosk/floor-plan-markers.tsx
new file mode 100644
index 0000000..49512df
--- /dev/null
+++ b/web/src/components/kiosk/floor-plan-markers.tsx
@@ -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) => (
+
+
+
+
+
+
+ {room.expand!.company!.short_name}
+
+
+ ))}
+ >
+ );
+}
diff --git a/web/src/components/kiosk/layout.tsx b/web/src/components/kiosk/layout.tsx
new file mode 100644
index 0000000..c7cc90a
--- /dev/null
+++ b/web/src/components/kiosk/layout.tsx
@@ -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 = {
+ "/": 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 (
+
+ {/* Left sidebar navigation panel */}
+
+
+ {navButtons.map(({ icon: Icon, path }) => {
+ const isActive = location === path;
+ const badgeCount = getBadgeCount(path);
+
+ return (
+
+
+
+ {badgeCount !== undefined && badgeCount > 0 && (
+
+ {badgeCount}
+
+ )}
+
+
+ );
+ })}
+
+
+
+ {/* Vertical divider between navigation and content */}
+
+
+ {/* Main content area */}
+
+ {/* Header with page title and live clock */}
+
+
{pageTitles[location] || "Page"}
+
+
+
{formatTime(currentTime)}
+
{formatDate(currentTime)}
+
+
+
+ {/* Separator below header */}
+
+
+ {/* Scrollable page content */}
+
{children}
+
+
+ );
+}
diff --git a/web/src/components/kiosk/notice-card.tsx b/web/src/components/kiosk/notice-card.tsx
new file mode 100644
index 0000000..f505bf0
--- /dev/null
+++ b/web/src/components/kiosk/notice-card.tsx
@@ -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 (
+
+
+ {notice.title}
+
+ {priorityLabels[notice.priority]}
+
+
+
+
+
+
+
+
+
+ {NOTICE_LABELS.posted}: {formatPostedDate(notice.created)}
+
+ •
+
+ {expired ? NOTICE_LABELS.expired : NOTICE_LABELS.validUntil}: {expiryDateStr}
+
+
+
+ );
+}
diff --git a/web/src/components/kiosk/room-info-panel.tsx b/web/src/components/kiosk/room-info-panel.tsx
new file mode 100644
index 0000000..e79bbe8
--- /dev/null
+++ b/web/src/components/kiosk/room-info-panel.tsx
@@ -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 (
+
+
+
+
+
+
+ {room.name}
+
+
+ {room.type && (
+ <>
+
•
+
{room.type}
+ >
+ )}
+
+ {room.expand?.company && (
+ <>
+
•
+
{room.expand.company.short_name}
+
{LOCATION_LABELS.occupied}
+ >
+ )}
+
+ {!room.expand?.company &&
{LOCATION_LABELS.available} }
+
+
+
+ {LOCATION_LABELS.close}
+
+
+
+
+ );
+}
diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx
new file mode 100644
index 0000000..0fbb3e1
--- /dev/null
+++ b/web/src/components/ui/badge.tsx
@@ -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 & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span";
+
+ return ;
+}
+
+export { Badge, badgeVariants };
diff --git a/web/src/constants/ui-text.ts b/web/src/constants/ui-text.ts
new file mode 100644
index 0000000..848a15f
--- /dev/null
+++ b/web/src/constants/ui-text.ts
@@ -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;
diff --git a/web/src/contexts/kiosk-data-context.tsx b/web/src/contexts/kiosk-data-context.tsx
new file mode 100644
index 0000000..3c90fa4
--- /dev/null
+++ b/web/src/contexts/kiosk-data-context.tsx
@@ -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({
+ about: null,
+ notices: [],
+ companies: [],
+ floorPlans: [],
+ loading: true,
+ error: null,
+});
+
+/* Provider component */
+export function KioskDataProvider({ children }: { children: ReactNode }) {
+ const [about, setAbout] = useState(null);
+ const [notices, setNotices] = useState([]);
+ const [companies, setCompanies] = useState([]);
+ const [floorPlans, setFloorPlans] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(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 (
+
+ {children}
+
+ );
+}
+
+/**
+ * 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;
+}
diff --git a/web/src/lib/pocketbase.ts b/web/src/lib/pocketbase.ts
new file mode 100644
index 0000000..bfcfe1d
--- /dev/null
+++ b/web/src/lib/pocketbase.ts
@@ -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;
diff --git a/web/src/lib/result.ts b/web/src/lib/result.ts
new file mode 100644
index 0000000..a91b9fd
--- /dev/null
+++ b/web/src/lib/result.ts
@@ -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, 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(fn: () => T): Result {
+ 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(fn: () => Promise): Promise> {
+ 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 */
+ }
+}
diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts
index 42c7369..33798f8 100644
--- a/web/src/lib/utils.ts
+++ b/web/src/lib/utils.ts
@@ -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) : "";
+}
diff --git a/web/src/routes/Routes.tsx b/web/src/routes/Routes.tsx
deleted file mode 100644
index ed3e7ff..0000000
--- a/web/src/routes/Routes.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-/* Wouter routing library */
-import { Router, Switch, Route } from "wouter";
-import { useHashLocation } from "wouter/use-hash-location";
-
-const Routes = () => (
-
-
- <>Home>} />
- <>NotFound>} />
-
-
-);
-
-export default Routes;
diff --git a/web/src/routes/about.tsx b/web/src/routes/about.tsx
new file mode 100644
index 0000000..b04b9df
--- /dev/null
+++ b/web/src/routes/about.tsx
@@ -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 (
+
+ {LOADING_MESSAGES.content}
+
+ );
+ }
+
+ /* Error state */
+ if (error) {
+ return (
+
+ {ERROR_MESSAGES.generic}
+
+ );
+ }
+
+ /* Empty state - no about content created yet */
+ if (!about) {
+ return (
+
+ {EMPTY_MESSAGES.noContent}
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/web/src/routes/bulletin.tsx b/web/src/routes/bulletin.tsx
new file mode 100644
index 0000000..add9e75
--- /dev/null
+++ b/web/src/routes/bulletin.tsx
@@ -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 (
+
+ {LOADING_MESSAGES.notices}
+
+ );
+ }
+
+ /* Error state */
+ if (error) {
+ return (
+
+ {ERROR_MESSAGES.generic}
+
+ );
+ }
+
+ return (
+
+ {/* Active notices section */}
+ {activeNotices.map((notice) => (
+
+ ))}
+
+ {/* Visual separator between active and expired notices */}
+ {expiredNotices.length > 0 && (
+
+
+ {SECTION_HEADERS.expiredNotices}
+
+
+ )}
+
+ {/* Expired notices section */}
+ {expiredNotices.map((notice) => (
+
+ ))}
+
+ );
+}
diff --git a/web/src/routes/directory.tsx b/web/src/routes/directory.tsx
new file mode 100644
index 0000000..6301a29
--- /dev/null
+++ b/web/src/routes/directory.tsx
@@ -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(null);
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [updatingCompanyId, setUpdatingCompanyId] = useState(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 => {
+ 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();
+
+ 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();
+ 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();
+
+ 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 (
+
+ {LOADING_MESSAGES.companies}
+
+ );
+ }
+
+ /* Error state */
+ if (error) {
+ return (
+
+ {ERROR_MESSAGES.generic}
+
+ );
+ }
+
+ return (
+
+ {/* Filter controls: Status filter and dynamic alphabet navigation */}
+
+ {/* Status filter: All, Present, or Absent companies */}
+
+ setStatusFilter("all")}
+ className="h-12"
+ >
+ {FILTER_LABELS.all}
+
+ setStatusFilter("present")}
+ className="h-12"
+ >
+ {FILTER_LABELS.present}
+
+ setStatusFilter("absent")}
+ className="h-12"
+ >
+ {FILTER_LABELS.absent}
+
+
+
+ {/* Dynamic alphabet: Only shows letters that exist in company data */}
+
+ {alphabet.map((letter) => {
+ const isAvailable = availableLetters.has(letter);
+ const isSelected = selectedLetter === letter;
+
+ return (
+ isAvailable && setSelectedLetter(isSelected ? null : letter)}
+ className={`h-14 w-14 p-0 ${!isAvailable ? "cursor-not-allowed opacity-40" : ""}`}
+ >
+ {letter}
+
+ );
+ })}
+
+
+
+ {/* Present companies grid (shown first for easy tenant sign-out) */}
+
+ {presentCompanies.map((company) => (
+
+ ))}
+
+
+ {/* Visual separator between present and absent sections */}
+ {absentCompanies.length > 0 && presentCompanies.length > 0 && (
+
+
+ {SECTION_HEADERS.absentCompanies}
+
+
+ )}
+
+ {/* Absent companies grid (shown below separator) */}
+ {absentCompanies.length > 0 && (
+
+ {absentCompanies.map((company) => (
+
+ ))}
+
+ )}
+
+ {/* Empty state: Shown when no companies match the filter criteria */}
+ {filteredCompanies.length === 0 && (
+
+
{EMPTY_MESSAGES.noCompanies}
+
+ )}
+
+ );
+}
diff --git a/web/src/routes/navigation.tsx b/web/src/routes/navigation.tsx
new file mode 100644
index 0000000..0e048ed
--- /dev/null
+++ b/web/src/routes/navigation.tsx
@@ -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("");
+ const [selectedAnnex, setSelectedAnnex] = useState("");
+ const [selectedRoom, setSelectedRoom] = useState(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) => {
+ 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 (
+
+ {LOADING_MESSAGES.floorPlans}
+
+ );
+ }
+
+ /* Error state */
+ if (error) {
+ return (
+
+ {ERROR_MESSAGES.generic}
+
+ );
+ }
+
+ return (
+
+ {/* Floor plan display with floating controls overlay */}
+ {currentFloorPlan ? (
+
+ {/**
+ * 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
+ */}
+
+ {/* Top row: First floor button + Annex filter buttons */}
+
+ {/* First floor button (corner position) */}
+ {floors.length > 0 && (
+ {
+ setSelectedFloor(floors[0]);
+ setSelectedRoom(null);
+ }}
+ className="h-14 w-14 p-0 font-bold shadow-md"
+ >
+ {floors[0].charAt(0)}
+
+ )}
+
+ {/* Annex filter buttons horizontally */}
+ {annexes.map((annex) => (
+ {
+ setSelectedAnnex(annex);
+ setSelectedRoom(null);
+ }}
+ className="h-14 w-14 p-0 font-bold shadow-md"
+ >
+ {annex}
+
+ ))}
+
+
+ {/* Remaining floor buttons vertically (Floor 1, 2, 3...) */}
+ {floors.slice(1).map((floor) => (
+
{
+ setSelectedFloor(floor);
+ setSelectedRoom(null);
+ }}
+ className="h-14 w-14 p-0 font-bold shadow-md"
+ >
+ {floor.charAt(0)}
+
+ ))}
+
+
+ {/**
+ * 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
+ */}
+
+
{
+ console.error("Failed to load floor plan:", getFileUrl(currentFloorPlan, currentFloorPlan.svg_file));
+ e.currentTarget.style.display = "none";
+ }}
+ />
+
+ {/* Company logos positioned on floor plan */}
+
+
+ {/* Coordinate helper - Shows x,y when Shift is held */}
+ {isShiftPressed && mousePosition && (
+
+ x: {mousePosition.x.toFixed(1)}%, y: {mousePosition.y.toFixed(1)}%
+
+ )}
+
+
+ {/* Floating room info panel - Appears at bottom-center when room selected */}
+ {selectedRoom &&
setSelectedRoom(null)} />}
+
+ ) : (
+ /* Empty state: Shown when no floor plan exists for current selection */
+
+
{EMPTY_MESSAGES.noFloorPlan}
+
+ )}
+
+ );
+}
diff --git a/web/src/routes/routes.tsx b/web/src/routes/routes.tsx
new file mode 100644
index 0000000..812553a
--- /dev/null
+++ b/web/src/routes/routes.tsx
@@ -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 = () => (
+
+
+
+
+
+
+
+
+ <>Not Found>} />
+
+
+
+
+);
+
+export default Routes;
diff --git a/web/src/services/about.ts b/web/src/services/about.ts
new file mode 100644
index 0000000..d9928c8
--- /dev/null
+++ b/web/src/services/about.ts
@@ -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 {
+ const records = await pb.collection(Collections.About).getFullList();
+ 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("*", async () => {
+ const about = await getAbout();
+ callback(about);
+ });
+
+ return () => {
+ safely(() => pb.collection(Collections.About).unsubscribe("*"));
+ };
+}
diff --git a/web/src/services/companies.ts b/web/src/services/companies.ts
new file mode 100644
index 0000000..38aeda1
--- /dev/null
+++ b/web/src/services/companies.ts
@@ -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 {
+ return await pb.collection(Collections.Companies).getFullList({
+ 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 {
+ return await pb.collection(Collections.Companies).update(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("*", async () => {
+ const companies = await getCompanies();
+ callback(companies);
+ });
+
+ return () => {
+ safely(() => pb.collection(Collections.Companies).unsubscribe("*"));
+ };
+}
diff --git a/web/src/services/floor-plans.ts b/web/src/services/floor-plans.ts
new file mode 100644
index 0000000..5fc974c
--- /dev/null
+++ b/web/src/services/floor-plans.ts
@@ -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 {
+ /* Fetch all floor plans */
+ const floorPlans = await pb.collection(Collections.FloorPlans).getFullList({
+ sort: "sort_order",
+ });
+
+ /* Fetch all rooms with company relations expanded */
+ const rooms = await pb.collection(Collections.Rooms).getFullList({
+ 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("*", async () => {
+ const floorPlans = await getFloorPlans();
+ callback(floorPlans);
+ });
+
+ pb.collection(Collections.Rooms).subscribe("*", async () => {
+ const floorPlans = await getFloorPlans();
+ callback(floorPlans);
+ });
+
+ return () => {
+ safely(() => {
+ pb.collection(Collections.FloorPlans).unsubscribe("*");
+ pb.collection(Collections.Rooms).unsubscribe("*");
+ });
+ };
+}
diff --git a/web/src/services/notices.ts b/web/src/services/notices.ts
new file mode 100644
index 0000000..470efca
--- /dev/null
+++ b/web/src/services/notices.ts
@@ -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 {
+ return await pb.collection(Collections.Notices).getFullList({
+ 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("*", async () => {
+ const notices = await getNotices();
+ callback(notices);
+ });
+
+ return () => {
+ safely(() => pb.collection(Collections.Notices).unsubscribe("*"));
+ };
+}
diff --git a/web/src/styles/tailwind.css b/web/src/styles/tailwind.css
index f4c1e9b..83844ec 100644
--- a/web/src/styles/tailwind.css
+++ b/web/src/styles/tailwind.css
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
+@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
diff --git a/web/src/types/about.ts b/web/src/types/about.ts
new file mode 100644
index 0000000..a5f2462
--- /dev/null
+++ b/web/src/types/about.ts
@@ -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 */;
+}
diff --git a/web/src/types/company.ts b/web/src/types/company.ts
new file mode 100644
index 0000000..45d4f92
--- /dev/null
+++ b/web/src/types/company.ts
@@ -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;
diff --git a/web/src/types/floor-plan.ts b/web/src/types/floor-plan.ts
new file mode 100644
index 0000000..1bba434
--- /dev/null
+++ b/web/src/types/floor-plan.ts
@@ -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[];
+}
diff --git a/web/src/types/notice.ts b/web/src/types/notice.ts
new file mode 100644
index 0000000..853672a
--- /dev/null
+++ b/web/src/types/notice.ts
@@ -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;
diff --git a/web/src/vite-root.tsx b/web/src/vite-root.tsx
index 87fe873..1456aa1 100644
--- a/web/src/vite-root.tsx
+++ b/web/src/vite-root.tsx
@@ -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(