Files
canvas-frontend/src/scenes/GeoAttendance.tsx
T
shad b5d1ea54b0 feat(geo): EA2-backed attendance sites + versioned seed scripts
Closes Oracle deferred caveats:
- raw-SQL persona promotion (now qa/seeds/001_personas.mjs, idempotent)
- no scripted multi-persona interaction (now qa/seeds/002_persona_
  interactions.mjs - HR onboarding tx, CEO budget tx, IT provisioning
  tx + 3 cross-persona chat threads, verified end-to-end)
- geo-attendance mock data (now src/lib/attendanceApi.ts pulls 8 sites
  via flow.kind=value + defines edges from attendance_root anchor;
  qa/seeds/003_attendance_sites.mjs seeds them idempotently)

Each site is a flow.kind=value doc with config.attendance = { lat,
lng, kind, status, who, city, label }. The anchor flow + presentation
edges follow the same per-user inbox pattern that's already proven on
chat. No hardcoded JS array left in src/scenes/GeoAttendance.tsx.
2026-06-14 14:07:57 +04:00

112 lines
4.4 KiB
TypeScript

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";
import { attendanceApi, type AttendanceSite } from "../lib/attendanceApi";
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],
});
function statusTag(s: AttendanceSite["status"]) {
if (s === "checked_in") return "ON-SITE";
if (s === "late") return "LATE";
return "OFF";
}
function FitToSites({ sites }: { sites: AttendanceSite[] }) {
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 pushToast = useApp((s) => s.pushToast);
const [filter, setFilter] = useState<"all" | AttendanceSite["kind"]>("all");
const [sites, setSites] = useState<AttendanceSite[] | null>(null);
useEffect(() => {
let cancelled = false;
attendanceApi
.listSites()
.then((rows) => !cancelled && setSites(rows))
.catch((err) => !cancelled && pushToast("err", `Sites failed: ${err.message}`));
return () => { cancelled = true; };
}, []);
const allSites = sites || [];
const visible = filter === "all" ? allSites : allSites.filter((s) => s.kind === filter);
const stats = allSites.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 (
<div className="geo-scene">
<header className="geo-head">
<div className="mc-hero-eyebrow"><Pulse size={12} /> Attendance map</div>
<h2 className="mc-hero-title">Where your people are</h2>
<p className="geo-intro">
Live sites and check-in statuses sourced from EA2. Add a site by inserting an attendance flow doc (kind=value, source_context=CANVAS_ATTENDANCE_SITE) and linking it to flow/attendance_root via a defines edge with role=presentation. Tiles are served by OpenStreetMap.
</p>
{sites !== null && allSites.length === 0 && (
<p className="geo-empty">No sites configured yet. Seed `qa/seeds/003_attendance_sites.mjs` to add the default canvas attendance roster.</p>
)}
<div className="geo-stats">
<span className="geo-stat geo-stat-ok">{stats.onSite} on-site</span>
<span className="geo-stat geo-stat-late">{stats.late} late</span>
<span className="geo-stat geo-stat-off">{stats.off} off-shift</span>
</div>
<div className="geo-filter-row">
{(["all", "store", "office", "warehouse"] as const).map((k) => (
<button
key={k}
className={`hub-chip ${filter === k ? "active" : ""}`}
onClick={() => setFilter(k)}
>
{k.toUpperCase()}
</button>
))}
<button className="hub-chip" onClick={() => setScene("hub-hr")}>People Hub</button>
</div>
</header>
<div className="geo-map-wrap">
<MapContainer center={[51.5074, -0.1278]} zoom={4} scrollWheelZoom={false} className="geo-map">
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<FitToSites sites={visible} />
{visible.map((s) => (
<Marker key={s._key} position={[s.lat, s.lng]}>
<Popup>
<strong>{s.label}</strong>
<br />
<span className={`geo-popup-tag geo-popup-${s.status}`}>{statusTag(s.status)}</span>
<br />
{s.who}
</Popup>
</Marker>
))}
</MapContainer>
</div>
</div>
);
}