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.short_name} +

{company.full_name}

+ + {location && ( +
+
+ + {location.floorPlan.floor} +
+ +
+ + + {LOCATION_LABELS.room} {location.room.name} + +
+
+ )} +
+ +
+ +
+ + + {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} +
+ + + {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 ( + + + + ); + })} +
+
+ + {/* 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}} +
+ + +
+
+
+ ); +} 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 */} +
+ + + +
+ + {/* Dynamic alphabet: Only shows letters that exist in company data */} +
+ {alphabet.map((letter) => { + const isAvailable = availableLetters.has(letter); + const isSelected = selectedLetter === letter; + + return ( + + ); + })} +
+
+ + {/* 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 && ( + + )} + + {/* Annex filter buttons horizontally */} + {annexes.map((annex) => ( + + ))} +
+ + {/* Remaining floor buttons vertically (Floor 1, 2, 3...) */} + {floors.slice(1).map((floor) => ( + + ))} +
+ + {/** + * 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 + */} +
+ {`${currentFloorPlan.floor} { + 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(