diff --git a/package.json b/package.json index de83eaf..efcdb3a 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,10 @@ "cmdk": "^1.1.1", "dagre": "^0.8.5", "framer-motion": "^12.40.0", + "leaflet": "^1.9.4", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-leaflet": "^4.2.1", "reactflow": "^11.11.4", "zustand": "^5.0.14" }, @@ -28,6 +30,7 @@ "@eslint/js": "^10.0.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/leaflet": "^1.9.21", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a13fe91..b415050 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,18 @@ importers: framer-motion: specifier: ^12.40.0 version: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + leaflet: + specifier: ^1.9.4 + version: 1.9.4 react: specifier: ^19.2.6 version: 19.2.7 react-dom: specifier: ^19.2.6 version: 19.2.7(react@19.2.7) + react-leaflet: + specifier: ^4.2.1 + version: 4.2.1(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) reactflow: specifier: ^11.11.4 version: 11.11.4(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -42,6 +48,9 @@ importers: '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/leaflet': + specifier: ^1.9.21 + version: 1.9.21 '@types/node': specifier: ^24.12.3 version: 24.13.2 @@ -492,6 +501,13 @@ packages: '@types/react': optional: true + '@react-leaflet/core@2.1.0': + resolution: {integrity: sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==} + peerDependencies: + leaflet: ^1.9.0 + react: ^18.0.0 + react-dom: ^18.0.0 + '@reactflow/background@11.3.14': resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==} peerDependencies: @@ -772,6 +788,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/leaflet@1.9.21': + resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} + '@types/node@24.13.2': resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} @@ -1297,6 +1316,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + leaflet@1.9.4: + resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -1499,6 +1521,13 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-leaflet@4.2.1: + resolution: {integrity: sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==} + peerDependencies: + leaflet: ^1.9.0 + react: ^18.0.0 + react-dom: ^18.0.0 + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -2270,6 +2299,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@react-leaflet/core@2.1.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + leaflet: 1.9.4 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@reactflow/background@11.3.14(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@reactflow/core': 11.11.4(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2572,6 +2607,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/leaflet@1.9.21': + dependencies: + '@types/geojson': 7946.0.16 + '@types/node@24.13.2': dependencies: undici-types: 7.18.2 @@ -3118,6 +3157,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + leaflet@1.9.4: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -3276,6 +3317,13 @@ snapshots: react-is@17.0.2: {} + react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@react-leaflet/core': 2.1.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + leaflet: 1.9.4 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): dependencies: react: 19.2.7 diff --git a/src/App.tsx b/src/App.tsx index af47172..342a37e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import SsoCallback from "./scenes/SsoCallback"; import Chat from "./scenes/Chat"; import Agent from "./scenes/Agent"; import Hub from "./scenes/Hub"; +import GeoAttendance from "./scenes/GeoAttendance"; import CommandBar from "./components/CommandBar"; import Toaster from "./components/Toaster"; import Console from "./components/Console"; @@ -163,6 +164,7 @@ export default function App() { {scene === "hub-procurement" && } {scene === "hub-hr" && } {scene === "hub-it" && } + {scene === "geo-attendance" && } {scene === "settings" && } diff --git a/src/index.css b/src/index.css index 750141b..dd9b2d1 100644 --- a/src/index.css +++ b/src/index.css @@ -1959,3 +1959,24 @@ select.studio-input { background: var(--bp-paper); } .hero-hub-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } .hub-chip { padding: 8px 14px; background: var(--bp-paper); border: 1px solid var(--bp-navy); color: var(--bp-navy); font-family: var(--bp-mono); font-size: 11px; letter-spacing: 0.04em; cursor: pointer; transition: background 0.1s; } .hub-chip:hover { background: color-mix(in srgb, var(--bp-amber) 20%, var(--bp-paper)); } + +.geo-scene { padding: 24px; max-width: 1400px; margin: 0 auto; background: var(--bp-paper); min-height: calc(100vh - 60px); } +.geo-head { margin-bottom: 20px; } +.geo-intro { font-size: 13px; color: var(--bp-muted); max-width: 720px; line-height: 1.5; margin-top: 6px; } +.geo-stats { display: flex; gap: 8px; margin-top: 12px; } +.geo-stat { font-family: var(--bp-mono); font-size: 11px; padding: 4px 10px; border: 1px solid var(--bp-navy); } +.geo-stat-ok { background: color-mix(in srgb, #4a8a3f 25%, var(--bp-paper)); } +.geo-stat-late { background: color-mix(in srgb, var(--bp-amber) 30%, var(--bp-paper)); } +.geo-stat-off { color: var(--bp-muted); } +.geo-filter-row { display: flex; gap: 6px; margin-top: 12px; flex-wrap: wrap; } +.hub-chip.active { background: var(--bp-amber); color: var(--bp-paper); } +.geo-map-wrap { border: 1px solid var(--bp-navy); height: 560px; overflow: hidden; } +.geo-map { width: 100%; height: 100%; } +.geo-popup-tag { font-family: var(--bp-mono); font-size: 10px; padding: 2px 6px; border: 1px solid currentColor; display: inline-block; margin: 2px 0; } +.geo-popup-checked_in { color: #2e7a25; } +.geo-popup-late { color: #c08020; } +.geo-popup-checked_out { color: #777; } + +@media (max-width: 700px) { + .geo-map-wrap { height: 380px; } +} diff --git a/src/scenes/GeoAttendance.tsx b/src/scenes/GeoAttendance.tsx new file mode 100644 index 0000000..9578c44 --- /dev/null +++ b/src/scenes/GeoAttendance.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from "react"; +import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet"; +import L from "leaflet"; +import "leaflet/dist/leaflet.css"; +import { useApp } from "../state/store"; +import { Pulse } from "../components/icons"; + +L.Marker.prototype.options.icon = L.icon({ + iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", + iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", + shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); + +interface Site { + id: string; + label: string; + kind: "store" | "office" | "warehouse"; + lat: number; + lng: number; + status: "checked_in" | "checked_out" | "late"; + who: string; + city: string; +} + +const SITES: Site[] = [ + { id: "hq-london", label: "HQ · London", kind: "office", lat: 51.5074, lng: -0.1278, status: "checked_in", who: "Mariana Cole (CEO) — on-site", city: "London" }, + { id: "store-204", label: "Store 204 · Manchester", kind: "store", lat: 53.4808, lng: -2.2426, status: "checked_in", who: "Manager Priya Sahota — opened 08:02", city: "Manchester" }, + { id: "store-118", label: "Store 118 · Birmingham", kind: "store", lat: 52.4862, lng: -1.8904, status: "late", who: "Manager Tom Reilly — late check-in 09:24", city: "Birmingham" }, + { id: "store-052", label: "Store 052 · Edinburgh", kind: "store", lat: 55.9533, lng: -3.1883, status: "checked_in", who: "Manager Iona MacDonald — opened 07:58", city: "Edinburgh" }, + { id: "office-berlin", label: "Office · Berlin", kind: "office", lat: 52.52, lng: 13.405, status: "checked_in", who: "Aisha Khan (HR Director) — remote-on", city: "Berlin" }, + { id: "warehouse-rotterdam", label: "Warehouse · Rotterdam", kind: "warehouse", lat: 51.9244, lng: 4.4777, status: "checked_in", who: "Shift A — 24 staff on-floor", city: "Rotterdam" }, + { id: "office-bangalore", label: "Office · Bangalore", kind: "office", lat: 12.9716, lng: 77.5946, status: "checked_in", who: "Rohan Patel (IT Director) — on-site", city: "Bangalore" }, + { id: "store-088", label: "Store 088 · Cardiff", kind: "store", lat: 51.4816, lng: -3.1791, status: "checked_out", who: "Manager Daniel Owens — closed 21:14 yesterday", city: "Cardiff" }, +]; + +function statusTag(s: Site["status"]) { + if (s === "checked_in") return "ON-SITE"; + if (s === "late") return "LATE"; + return "OFF"; +} + +function FitToSites({ sites }: { sites: Site[] }) { + const map = useMap(); + useEffect(() => { + if (!sites.length) return; + const bounds = L.latLngBounds(sites.map((s) => [s.lat, s.lng] as [number, number])); + map.fitBounds(bounds, { padding: [40, 40] }); + }, [map, sites]); + return null; +} + +export default function GeoAttendance() { + const setScene = useApp((s) => s.setScene); + const [filter, setFilter] = useState<"all" | Site["kind"]>("all"); + const visible = filter === "all" ? SITES : SITES.filter((s) => s.kind === filter); + const stats = SITES.reduce( + (acc, s) => ({ + onSite: acc.onSite + (s.status === "checked_in" ? 1 : 0), + late: acc.late + (s.status === "late" ? 1 : 0), + off: acc.off + (s.status === "checked_out" ? 1 : 0), + }), + { onSite: 0, late: 0, off: 0 } + ); + + return ( +
+
+
Real-time attendance
+

Where your people are right now

+

+ Live attendance for stores, offices, and warehouses. Click a marker to see who's checked in and when. Powered by OpenStreetMap tiles — no proprietary mapping key required. +

+
+ {stats.onSite} on-site + {stats.late} late + {stats.off} off-shift +
+
+ {(["all", "store", "office", "warehouse"] as const).map((k) => ( + + ))} + +
+
+ +
+ + + + {visible.map((s) => ( + + + {s.label} +
+ {statusTag(s.status)} +
+ {s.who} +
+
+ ))} +
+
+
+ ); +} diff --git a/src/scenes/Landing.tsx b/src/scenes/Landing.tsx index 20fcd44..3b0f320 100644 --- a/src/scenes/Landing.tsx +++ b/src/scenes/Landing.tsx @@ -79,6 +79,7 @@ export default function Landing() { +