Version 1.0 squash

This commit is contained in:
Kenan Alić
2025-10-21 18:45:02 +02:00
parent 8c1f041fa0
commit a481044c43
30 changed files with 2534 additions and 15 deletions

508
schema/pb_schema.json Normal file
View File

@@ -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
}
]

View File

@@ -7,6 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier -w **/*",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -18,6 +19,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"pocketbase": "^0.26.2",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
@@ -26,6 +28,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^24.5.2", "@types/node": "^24.5.2",
"@types/react": "^19.1.13", "@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",

42
web/pnpm-lock.yaml generated
View File

@@ -35,6 +35,9 @@ importers:
lucide-react: lucide-react:
specifier: ^0.544.0 specifier: ^0.544.0
version: 0.544.0(react@19.1.1) version: 0.544.0(react@19.1.1)
pocketbase:
specifier: ^0.26.2
version: 0.26.2
react: react:
specifier: ^19.1.1 specifier: ^19.1.1
version: 19.1.1 version: 19.1.1
@@ -54,6 +57,9 @@ importers:
'@eslint/js': '@eslint/js':
specifier: ^9.36.0 specifier: ^9.36.0
version: 9.36.0 version: 9.36.0
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@4.1.13)
'@types/node': '@types/node':
specifier: ^24.5.2 specifier: ^24.5.2
version: 24.5.2 version: 24.5.2
@@ -733,6 +739,11 @@ packages:
resolution: {integrity: sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==} resolution: {integrity: sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==}
engines: {node: '>= 10'} 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': '@tailwindcss/vite@4.1.13':
resolution: {integrity: sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ==} resolution: {integrity: sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ==}
peerDependencies: peerDependencies:
@@ -924,6 +935,11 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
hasBin: true
csstype@3.1.3: csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@@ -1322,6 +1338,13 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'} 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: postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@@ -1625,6 +1648,9 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 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: which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -2234,6 +2260,11 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.13 '@tailwindcss/oxide-win32-arm64-msvc': 4.1.13
'@tailwindcss/oxide-win32-x64-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))': '@tailwindcss/vite@4.1.13(rolldown-vite@7.1.12(@types/node@24.5.2)(jiti@2.6.0))':
dependencies: dependencies:
'@tailwindcss/node': 4.1.13 '@tailwindcss/node': 4.1.13
@@ -2471,6 +2502,8 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
cssesc@3.0.0: {}
csstype@3.1.3: {} csstype@3.1.3: {}
debug@4.4.3: debug@4.4.3:
@@ -2811,6 +2844,13 @@ snapshots:
picomatch@4.0.3: {} 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: postcss@8.5.6:
dependencies: dependencies:
nanoid: 3.3.11 nanoid: 3.3.11
@@ -3009,6 +3049,8 @@ snapshots:
dependencies: dependencies:
react: 19.1.1 react: 19.1.1
util-deprecate@1.0.2: {}
which@2.0.2: which@2.0.2:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0

View 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>
);
}

View 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>
))}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 };

View 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;

View 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
View 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
View 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 */
}
}

View File

@@ -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 */ /* Shadcn */
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { clsx, type ClassValue } from "clsx"; 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)); const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
export { cn }; 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) : "";
}

View File

@@ -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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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("*"));
};
}

View 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("*"));
};
}

View 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("*");
});
};
}

View 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("*"));
};
}

View File

@@ -1,5 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));

19
web/src/types/about.ts Normal file
View 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
View 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;

View 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
View 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;

View File

@@ -6,7 +6,7 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
/* Route-related */ /* Route-related */
import Routes from "@/routes/Routes"; import Routes from "@/routes/routes";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>