From c430b0b8b2e582778fb0be99aa7ff709b0ac498e Mon Sep 17 00:00:00 2001 From: evanqua Date: Tue, 11 Aug 2026 19:36:09 -0700 Subject: [PATCH 01/16] feat: refactor profile page and add self-service password reset Strips the profile page down to essentials (info, security, preferences) and adds an admin section for managing event certifications and other admins, backed by a new isAdmin flag on the user doc. Adds a proper reduced-motion preference (previously only OS-driven), a collapsible password-change form, a redesigned edit-profile page, and a self-service "forgot password" flow using each backend's built-in email reset link so locked-out users don't need admin intervention. Co-Authored-By: Claude Sonnet 5 --- README.md | 24 + docs/FIREBASE_SETUP.md | 17 + firestore.rules | 21 +- playwright.config.ts | 7 + scripts/setAdmin.js | 46 ++ scripts/setAdminPocketbase.js | 95 ++++ scripts/setup-pocketbase.js | 77 ++- .../(main)/events/[eventId]/create/page.tsx | 11 +- .../(main)/events/[eventId]/dispatch/page.tsx | 3 +- src/app/globals.css | 28 ++ src/app/layout.tsx | 8 + src/app/profile/edit/page.tsx | 92 ++-- src/app/profile/page.tsx | 471 +----------------- src/app/reset-password/page.tsx | 151 ++++++ src/components/modals/auth/loginmodal.tsx | 214 +++++--- .../profile/admin-certifications-section.tsx | 99 ++++ src/components/profile/admin-section.tsx | 20 + .../profile/admin-users-section.tsx | 122 +++++ .../profile/preferences-section.tsx | 28 ++ .../profile/profile-info-section.tsx | 28 ++ src/components/profile/security-section.tsx | 353 +++++++++++++ src/hooks/useAdmin.ts | 41 ++ src/hooks/useCertifications.ts | 28 ++ src/hooks/useReducedMotion.ts | 34 ++ src/hooks/useauth.ts | 22 +- src/lib/certificationsService.ts | 38 ++ src/lib/services/IAuthService.ts | 11 + .../services/firebase/FirebaseAuthService.ts | 22 + .../pocketbase/PocketbaseAuthService.ts | 19 + src/lib/userDoc.ts | 8 + tests/e2e/features/auth-modal.feature | 27 + tests/e2e/features/navigation.feature | 2 +- tests/e2e/features/profile-edit.feature | 2 +- tests/e2e/features/profile.feature | 60 +-- tests/e2e/features/reset-password.feature | 27 + tests/e2e/steps/auth.steps.ts | 4 + tests/e2e/steps/profile.steps.ts | 2 +- 37 files changed, 1643 insertions(+), 619 deletions(-) create mode 100644 scripts/setAdmin.js create mode 100644 scripts/setAdminPocketbase.js create mode 100644 src/app/reset-password/page.tsx create mode 100644 src/components/profile/admin-certifications-section.tsx create mode 100644 src/components/profile/admin-section.tsx create mode 100644 src/components/profile/admin-users-section.tsx create mode 100644 src/components/profile/preferences-section.tsx create mode 100644 src/components/profile/profile-info-section.tsx create mode 100644 src/components/profile/security-section.tsx create mode 100644 src/hooks/useAdmin.ts create mode 100644 src/hooks/useCertifications.ts create mode 100644 src/hooks/useReducedMotion.ts create mode 100644 src/lib/certificationsService.ts create mode 100644 src/lib/userDoc.ts create mode 100644 tests/e2e/features/reset-password.feature diff --git a/README.md b/README.md index c126949a..0342b3fb 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,15 @@ node scripts/setup-pocketbase.js 5. The app is available at `http://localhost:3000` and the PocketBase admin UI at `http://localhost:8090/_/`. +6. Grant yourself admin access (first time only — needed for Profile > Admin, e.g. managing the certification list): +```bash +PB_URL=http://127.0.0.1:8090 PB_ADMIN_EMAIL=admin@example.com PB_ADMIN_PASSWORD=YourPassword! \ +node scripts/setAdminPocketbase.js you@example.com +``` +> This is only needed once — after signing in, that user can grant/revoke admin access for others from Profile > Admin > Manage Admins. + +7. (Optional) Enable "Forgot password" emails: in the PocketBase admin UI, configure **Settings > Mail settings** with real SMTP credentials, then update the **Collections > users > Options > Email templates > Reset password** action URL to `{APP_URL}/reset-password?token={TOKEN}` (replacing `{APP_URL}` with your app's URL) so the link opens this app instead of PocketBase's own admin UI. Without this, users can't self-serve a forgotten password. `scripts/setup-pocketbase.js` also prints this reminder. + To stop the stack: `docker compose down`. Your data is preserved in `.pb-data/` and will be available on the next `docker compose up`. **With Docker + Firebase** @@ -172,6 +181,21 @@ npm run build && npm start The app will be available at `http://localhost:3000` and will communicate with PocketBase via the URL you configured. +**6. Grant yourself admin access** + +One-time bootstrap step for Profile > Admin (e.g. managing the certification list): + +```bash +PB_URL=http://192.168.x.x:8090 PB_ADMIN_EMAIL=admin@example.com PB_ADMIN_PASSWORD=YourPassword! \ +node scripts/setAdminPocketbase.js you@example.com +``` + +After signing in, that user can grant/revoke admin access for others from Profile > Admin > Manage Admins. + +**7. (Optional) Enable "Forgot password" emails** + +Configure **Settings > Mail settings** in the PocketBase admin UI with real SMTP credentials, then update the **Collections > users > Options > Email templates > Reset password** action URL to `{APP_URL}/reset-password?token={TOKEN}` so the link opens this app instead of PocketBase's own admin UI. + #### Testing The E2E suite uses Playwright BDD with Firebase emulators. The test runner starts the emulators and a production build of Next.js automatically — no manual server setup required. diff --git a/docs/FIREBASE_SETUP.md b/docs/FIREBASE_SETUP.md index 42acd479..7a5e77cd 100644 --- a/docs/FIREBASE_SETUP.md +++ b/docs/FIREBASE_SETUP.md @@ -63,6 +63,23 @@ Use the emulators when developing Firestore rules and client workflows. - For production consider enabling SSO providers (Google, OIDC) and enforce MFA for admin users. - Create service accounts for CI and server-side tasks with least privilege. +## Granting admin access + +CrowdCAD's app-level admin role (Profile > Admin — manages the certification list and other admins) is separate from Firebase IAM/service accounts above. It's a boolean `isAdmin` field on the user's `users/{uid}` Firestore document. + +There's no signup-time or console way to set it, so the first admin on a deployment must be bootstrapped with a script: + +```bash +GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json \ +node scripts/setAdmin.js admin@example.com +``` + +This requires a service account JSON (Firebase Console > Project Settings > Service Accounts > Generate new private key). Once the first admin signs in, they can grant or revoke admin access for other users from the "Manage Admins" panel in Profile > Admin — the script is only needed once per deployment. + +## Forgot-password emails + +The "Forgot password?" link on the login screen uses Firebase Auth's built-in `sendPasswordResetEmail` — Firebase sends and delivers the email itself, no SMTP config needed. The only requirement is that your deployed domain (and `localhost` for local dev) is listed under **Authentication > Settings > Authorized domains** in the Firebase Console — this is usually already the case for any domain you're using to sign in, since Firebase Auth requires it for sign-in to work at all. + ## CI & production deploys - Store `FIREBASE_PROJECT` and `FIREBASE_TOKEN` (or use Workload Identity Federation) in your CI secrets. diff --git a/firestore.rules b/firestore.rules index 8cea65cc..8b26e4dc 100644 --- a/firestore.rules +++ b/firestore.rules @@ -34,9 +34,26 @@ service cloud.firestore { allow update, delete: if isEventOwnerOrOrgMember(eventId); } - // Users: allow users to read/write their own profile + // Users: allow users to read/write their own profile; admins may also + // read/write any user's doc (needed to grant/revoke isAdmin from the + // Profile > Admin > Manage Admins panel). match /users/{userId} { - allow read, write: if request.auth != null && request.auth.uid == userId; + allow read, write: if request.auth != null && + (request.auth.uid == userId || isRequestingUserAdmin()); + } + + // Settings: app-wide admin-managed config (e.g. the certification list + // offered when adding team members). Readable by any authenticated user + // since it's needed during event creation; writable only by admins. + match /settings/{settingId} { + allow read: if request.auth != null; + allow write: if isRequestingUserAdmin(); + } + + function isRequestingUserAdmin() { + return request.auth != null && + exists(/databases/$(database)/documents/users/$(request.auth.uid)) && + get(/databases/$(database)/documents/users/$(request.auth.uid)).data.isAdmin == true; } // Fallback deny diff --git a/playwright.config.ts b/playwright.config.ts index 514a7b18..54e23747 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -57,6 +57,13 @@ export default defineConfig({ webServer: [ { command: 'npx firebase emulators:start --only auth,firestore,storage --project demo-crowdcad', + // The emulator hub's port — lets Playwright detect an already-running + // instance and skip re-spawning it. Without this, Playwright always + // launches a fresh `firebase emulators:start`, which either races the + // `next build` step below (proceeding before the emulator is actually + // ready) or, if one is already running, fails to bind its ports and + // can take the healthy instance down with it. + port: 4400, reuseExistingServer: !process.env.CI, timeout: 120_000, stdout: 'pipe', diff --git a/scripts/setAdmin.js b/scripts/setAdmin.js new file mode 100644 index 00000000..5da3171e --- /dev/null +++ b/scripts/setAdmin.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +/* +Grants (or revokes) admin access for a CrowdCAD user, identified by email, by +setting `isAdmin` on their `users/{uid}` Firestore document. + +This is a one-time bootstrap step for the *first* admin on a deployment — +once at least one admin exists, further admins can be granted/revoked from +the "Manage Admins" panel in Profile > Admin. + +Usage: + node scripts/setAdmin.js # grant admin + node scripts/setAdmin.js --revoke # revoke admin + +Ensure you have a service account JSON and set `GOOGLE_APPLICATION_CREDENTIALS`. +*/ + +const admin = require('firebase-admin'); + +if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) { + console.error('Set GOOGLE_APPLICATION_CREDENTIALS to a service account JSON path before running.'); + process.exit(1); +} + +const email = process.argv[2]; +const revoke = process.argv.includes('--revoke'); + +if (!email) { + console.error('Usage: node scripts/setAdmin.js [--revoke]'); + process.exit(1); +} + +admin.initializeApp(); + +async function run() { + const userRecord = await admin.auth().getUserByEmail(email); + await admin.firestore().collection('users').doc(userRecord.uid).set( + { isAdmin: !revoke }, + { merge: true }, + ); + console.log(`${revoke ? 'Revoked' : 'Granted'} admin access for ${email} (uid: ${userRecord.uid})`); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/setAdminPocketbase.js b/scripts/setAdminPocketbase.js new file mode 100644 index 00000000..97977665 --- /dev/null +++ b/scripts/setAdminPocketbase.js @@ -0,0 +1,95 @@ +#!/usr/bin/env node +/* +Grants (or revokes) admin access for a CrowdCAD user, identified by email, by +setting `isAdmin` on their record in the PocketBase `users` collection. + +This is a one-time bootstrap step for the *first* admin on a deployment — +once at least one admin exists, further admins can be granted/revoked from +the "Manage Admins" panel in Profile > Admin. + +Prerequisites: + - PocketBase is running and reachable at PB_URL + - `node scripts/setup-pocketbase.js` has been run (adds the `isAdmin` field) + +Usage: + PB_URL=http://192.168.x.x:8090 \ + PB_ADMIN_EMAIL=admin@example.com \ + PB_ADMIN_PASSWORD=YourPassword! \ + node scripts/setAdminPocketbase.js [--revoke] + +All PB_* env vars can also be placed in a .env.local file. +*/ + +try { + require('dotenv').config({ path: require('path').join(__dirname, '..', '.env.local') }); +} catch { + // dotenv not available — rely on env vars being set externally +} + +const PB_URL = (process.env.PB_URL ?? 'http://127.0.0.1:8090').replace(/\/$/, ''); +const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL; +const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD; + +const targetEmail = process.argv[2]; +const revoke = process.argv.includes('--revoke'); + +if (!ADMIN_EMAIL || !ADMIN_PASSWORD) { + console.error('Error: PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD must be set.'); + process.exit(1); +} +if (!targetEmail) { + console.error('Usage: node scripts/setAdminPocketbase.js [--revoke]'); + process.exit(1); +} + +async function pbFetch(apiPath, options = {}) { + return fetch(`${PB_URL}${apiPath}`, options); +} + +async function getAdminToken() { + const res = await pbFetch('/api/collections/_superusers/auth-with-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identity: ADMIN_EMAIL, password: ADMIN_PASSWORD }), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Superadmin authentication failed: ${res.status} — ${body}`); + } + const { token } = await res.json(); + return token; +} + +async function main() { + const token = await getAdminToken(); + const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }; + + const filter = encodeURIComponent(`email = "${targetEmail}"`); + const listRes = await pbFetch(`/api/collections/users/records?filter=${filter}`, { headers }); + if (!listRes.ok) { + const body = await listRes.text(); + throw new Error(`Failed to look up user '${targetEmail}': ${listRes.status} — ${body}`); + } + const { items } = await listRes.json(); + if (!items || items.length === 0) { + throw new Error(`No user found with email '${targetEmail}'`); + } + const user = items[0]; + + const patchRes = await pbFetch(`/api/collections/users/records/${user.id}`, { + method: 'PATCH', + headers, + body: JSON.stringify({ isAdmin: !revoke }), + }); + if (!patchRes.ok) { + const body = await patchRes.text(); + throw new Error(`Failed to update user '${targetEmail}': ${patchRes.status} — ${body}`); + } + + console.log(`${revoke ? 'Revoked' : 'Granted'} admin access for ${targetEmail} (id: ${user.id})`); +} + +main().catch((err) => { + console.error(err.message); + process.exit(1); +}); diff --git a/scripts/setup-pocketbase.js b/scripts/setup-pocketbase.js index accaa4bb..74201caf 100644 --- a/scripts/setup-pocketbase.js +++ b/scripts/setup-pocketbase.js @@ -62,13 +62,14 @@ async function getAdminToken() { return token; } -async function ensureCollection(headers, name, fields) { +async function ensureCollection(headers, name, fields, rules) { const check = await pbFetch(`/api/collections/${name}`, { headers }); if (check.ok) { console.log(` [skip] ${name} — already exists`); return; } + const authRule = '@request.auth.id != ""'; const res = await pbFetch('/api/collections', { method: 'POST', headers, @@ -78,11 +79,11 @@ async function ensureCollection(headers, name, fields) { fields, // Restrict access to authenticated users by default. // Adjust these rules in the PocketBase admin UI to match your security policy. - listRule: '@request.auth.id != ""', - viewRule: '@request.auth.id != ""', - createRule: '@request.auth.id != ""', - updateRule: '@request.auth.id != ""', - deleteRule: '@request.auth.id != ""', + listRule: rules?.listRule ?? authRule, + viewRule: rules?.viewRule ?? authRule, + createRule: rules?.createRule ?? authRule, + updateRule: rules?.updateRule ?? authRule, + deleteRule: rules?.deleteRule ?? authRule, }), }); @@ -93,6 +94,31 @@ async function ensureCollection(headers, name, fields) { console.log(` [create] ${name}`); } +async function ensureField(headers, collectionName, field) { + const res = await pbFetch(`/api/collections/${collectionName}`, { headers }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Failed to read collection '${collectionName}': ${res.status} — ${body}`); + } + const collection = await res.json(); + const existing = collection.fields || []; + if (existing.some((f) => f.name === field.name)) { + console.log(` [skip] ${collectionName}.${field.name} — already exists`); + return; + } + + const patchRes = await pbFetch(`/api/collections/${collectionName}`, { + method: 'PATCH', + headers, + body: JSON.stringify({ fields: [...existing, field] }), + }); + if (!patchRes.ok) { + const body = await patchRes.text(); + throw new Error(`Failed to add field '${field.name}' to '${collectionName}': ${patchRes.status} — ${body}`); + } + console.log(` [add] ${collectionName}.${field.name}`); +} + async function main() { console.log(`Connecting to PocketBase at ${PB_URL} ...`); @@ -150,10 +176,49 @@ async function main() { { name: 'file', type: 'file', options: { maxSelect: 1, maxSize: 52428800 } }, ]); + await ensureCollection( + headers, + 'settings', + [ + { name: 'key', type: 'text', required: true }, + { name: 'list', type: 'json' }, + ], + { + // Readable by any authenticated user (needed at event-create time); + // writable only by admins. + listRule: '@request.auth.id != ""', + viewRule: '@request.auth.id != ""', + createRule: '@request.auth.isAdmin = true', + updateRule: '@request.auth.isAdmin = true', + deleteRule: '@request.auth.isAdmin = true', + }, + ); + + // `isAdmin` on the built-in `users` auth collection — grants access to the + // Profile > Admin section. Grant it per-user via scripts/setAdminPocketbase.js + // (or the Manage Admins panel, once at least one admin exists). + await ensureField(headers, 'users', { name: 'isAdmin', type: 'bool' }); + console.log('\nDone. CrowdCAD collections are ready.'); console.log( 'Review access rules in the PocketBase admin UI at ' + PB_URL + '/_/ before going to production.', ); + console.log( + "\nSecurity: restrict the built-in 'users' collection's List/View/Update rules to\n" + + ' @request.auth.id = id || @request.auth.isAdmin = true\n' + + 'in the admin UI (Collections > users > API Rules) — otherwise any authenticated\n' + + "user may be able to list other users or flip their own 'isAdmin' field. This\n" + + "isn't set automatically so it doesn't overwrite rules you've already customized.", + ); + console.log( + "\nForgot-password emails: PocketBase's default reset-password email links to its\n" + + "own admin UI, not this app. In the admin UI go to Collections > users > Options >\n" + + 'Email templates > Reset password, and change the action URL to:\n' + + ' {APP_URL}/reset-password?token={TOKEN}\n' + + "(replace {APP_URL} with your deployed app's URL). Also configure Settings > Mail\n" + + "settings with real SMTP credentials — without it, PocketBase can't send these\n" + + 'emails at all. Neither of these is set automatically for the same reason as above.', + ); } main().catch((err) => { diff --git a/src/app/(main)/events/[eventId]/create/page.tsx b/src/app/(main)/events/[eventId]/create/page.tsx index c5e7b6ba..3c1a8c0b 100644 --- a/src/app/(main)/events/[eventId]/create/page.tsx +++ b/src/app/(main)/events/[eventId]/create/page.tsx @@ -14,6 +14,7 @@ import MapPanSurface from '@/components/ui/map-pan-surface'; import { useScheduleGeneration } from '@/hooks/useScheduleGeneration'; import { useTeamForm } from '@/hooks/useTeamForm'; import { useZoomPan } from '@/hooks/useZoomPan'; +import { useCertifications } from '@/hooks/useCertifications'; import MetadataSection from '@/components/event-create/MetadataSection'; import TeamStaffingSection from '@/components/event-create/TeamStaffingSection'; import SupervisorStaffingSection from '@/components/event-create/SupervisorStaffingSection'; @@ -26,8 +27,6 @@ import BulkImportModal from '@/components/modals/event/bulkimportmodal'; import LoadingScreen from '@/components/ui/loading-screen'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; -const LICENSES = ['FA', 'FR', 'CPR', 'EMT-B', 'EMT-A', 'EMT-P', 'RN', 'MD/DO']; - // Helper to get post name regardless of type const getPostName = (post: Post): string => { return typeof post === 'string' ? post : post.name; @@ -39,6 +38,8 @@ export default function EventCreation() { const params = useParams(); const eventId = params?.eventId as string | undefined; + const { certifications } = useCertifications(); + const [loading, setLoading] = useState(true); const [eventData, setEventData] = useState & { eventEquipment: EventEquipment[] }>({ name: '', @@ -821,7 +822,7 @@ export default function EventCreation() { addMember={addMember} currentMembers={currentMembers} removeMember={removeMember} - roles={LICENSES.map(name => ({ name, fullName: name }))} + roles={certifications.map(name => ({ name, fullName: name }))} /> ({ name, fullName: name }))} + roles={certifications.map(name => ({ name, fullName: name }))} /> setBulkImportMode(null)} mode={bulkImportMode || 'team'} - roles={LICENSES.map(name => ({ name, fullName: name }))} + roles={certifications.map(name => ({ name, fullName: name }))} existingTeamNames={ bulkImportMode === 'supervisor' ? (eventData.supervisor || []).map(s => s.team) diff --git a/src/app/(main)/events/[eventId]/dispatch/page.tsx b/src/app/(main)/events/[eventId]/dispatch/page.tsx index 062447c3..99feaf4c 100644 --- a/src/app/(main)/events/[eventId]/dispatch/page.tsx +++ b/src/app/(main)/events/[eventId]/dispatch/page.tsx @@ -14,6 +14,7 @@ import { toast, Slide } from 'react-toastify'; import { useRouter } from 'next/navigation'; import isEqual from 'lodash.isequal'; import { useAuth } from '@/hooks/useauth'; +import { useCertifications } from '@/hooks/useCertifications'; import { useLiteMode } from '@/lib/LiteContext'; import { deleteLiteEvent, getLiteEvent, saveLiteEvent } from '@/lib/liteEventStore'; import { Plus, RotateCw, ArrowDownWideNarrow, Rows2, Rows4} from "lucide-react"; @@ -100,7 +101,7 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { const [isTeamLead, setIsTeamLead] = useState(false); const [currentMembers, setCurrentMembers] = useState<{ name: string, cert: string, lead: boolean }[]>([]); const [editTeamOriginalName, setEditTeamOriginalName] = useState(null); - const LICENSES = ['FA', 'FR', 'CPR', 'EMT-B', 'EMT-A', 'EMT-P', 'RN', 'MD/DO']; + const { certifications: LICENSES } = useCertifications(); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; diff --git a/src/app/globals.css b/src/app/globals.css index ed1c818e..e5e958e8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -192,6 +192,13 @@ } } +.reduce-motion .dispatch-expand-grid, +.reduce-motion .dispatch-expand-fade { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; +} + @keyframes cell-ripple { 0% { opacity: calc(0.3 * (1 - var(--distance-ratio, 0))); @@ -351,6 +358,27 @@ grammarly-card, } } +.reduce-motion *, .reduce-motion *::before, .reduce-motion *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; +} + +.reduce-motion .animate-aurora { + animation: none !important; + opacity: 0.18 !important; +} + +.reduce-motion .hero-cta-gradient { + animation: none !important; + background-position: 50% 50%; +} + +.reduce-motion .hero-cta-gradient::before { + animation: none !important; + background-position: 50% 50%; +} + /* ── NProgress loading bar styles ──────────────────────────── */ #nprogress { pointer-events: none; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index de951f43..a5289a38 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -25,6 +25,14 @@ export default function RootLayout({ children }: { children: React.ReactNode }) document.documentElement.classList.add('dark'); document.documentElement.setAttribute('data-theme', 'dark'); } + try { + var reducedMotion = localStorage.getItem('ccad-reduced-motion') === '1'; + var root2 = document.documentElement; + root2.classList.toggle('reduce-motion', reducedMotion); + root2.setAttribute('data-reduced-motion', String(reducedMotion)); + } catch (e) { + // localStorage unavailable — falls back to OS prefers-reduced-motion via CSS + } })(); `, }} diff --git a/src/app/profile/edit/page.tsx b/src/app/profile/edit/page.tsx index a9d0a2c2..6eddd704 100644 --- a/src/app/profile/edit/page.tsx +++ b/src/app/profile/edit/page.tsx @@ -3,17 +3,23 @@ import React, { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/hooks/useauth'; -import { authService, dbService, isPocketbaseBackend } from '@/lib/services'; -import { Card, CardBody, Button, Avatar } from '@heroui/react'; +import { authService, dbService } from '@/lib/services'; +import { Card, CardBody, Button, Avatar, Input } from '@heroui/react'; +import { DiagonalStreaksFixed } from '@/components/ui/diagonal-streaks-fixed'; import LoadingScreen from '@/components/ui/loading-screen'; +const inputClassNames = { + label: 'text-surface-light font-medium', + input: 'text-surface-light outline-none focus:outline-none data-[focus=true]:outline-none focus:ring-0 focus-visible:ring-0', + inputWrapper: 'rounded-2xl px-4 hover:bg-surface-deep', +} as const; + export default function EditProfilePage() { const { user, ready } = useAuth(); const router = useRouter(); const [displayName, setDisplayName] = useState(''); const [phone, setPhone] = useState(''); - const [photoURL, setPhotoURL] = useState(''); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(null); @@ -21,7 +27,6 @@ export default function EditProfilePage() { if (!ready) return; if (!user) return; setDisplayName(user.displayName ?? ''); - setPhotoURL(user.photoURL ?? ''); setPhone(user.phoneNumber ?? ''); }, [user, ready]); @@ -35,10 +40,7 @@ export default function EditProfilePage() { try { const currentUser = authService.currentUser; if (currentUser) { - await authService.updateProfile({ - displayName: displayName || null, - ...(photoURL && !isPocketbaseBackend ? { photoURL } : {}), - }); + await authService.updateProfile({ displayName: displayName || null }); // Save phone (and other profile metadata) to users collection await dbService.setDocument('users', currentUser.uid, { phoneNumber: phone || null }, { merge: true }); @@ -57,10 +59,15 @@ export default function EditProfilePage() { }; return ( -
-
+
+ +
- +
@@ -72,41 +79,42 @@ export default function EditProfilePage() {
-
- - setDisplayName(e.target.value)} - className="w-full px-4 py-2 bg-surface-deepest text-surface-light border border-surface rounded-md focus:outline-none focus:ring-2 focus:ring-status-blue" - placeholder="Your full name" - /> -
+ setDisplayName(e.target.value)} + placeholder="Your full name" + classNames={inputClassNames} + /> -
- - setPhotoURL(e.target.value)} - className="w-full px-4 py-2 bg-surface-deepest text-surface-light border border-surface rounded-md focus:outline-none focus:ring-2 focus:ring-status-blue" - placeholder="https://..." - /> -
- -
- - setPhone(e.target.value)} - className="w-full px-4 py-2 bg-surface-deepest text-surface-light border border-surface rounded-md focus:outline-none focus:ring-2 focus:ring-status-blue" - placeholder="+1 555 555 5555" - /> -

Phone numbers are saved to your profile document.

-
+ setPhone(e.target.value)} + placeholder="+1 555 555 5555" + description="Phone numbers are saved to your profile document." + classNames={inputClassNames} + />
- - + + {message && {message}}
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 551e2c76..7d370abf 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -1,479 +1,28 @@ 'use client'; -import React, { useEffect, useState } from 'react'; import { useAuth } from '@/hooks/useauth'; -import { Avatar, Button, Card, CardBody, Input, Select, SelectItem, Tabs, Tab } from '@heroui/react'; -import { authService, dbService, ServiceError } from '@/lib/services'; import { DiagonalStreaksFixed } from '@/components/ui/diagonal-streaks-fixed'; import LoadingScreen from '@/components/ui/loading-screen'; -import { User, Shield, Database, Settings, LogOut, Trash2, Download, Eye, EyeOff } from 'lucide-react'; +import ProfileInfoSection from '@/components/profile/profile-info-section'; +import SecuritySection from '@/components/profile/security-section'; +import PreferencesSection from '@/components/profile/preferences-section'; +import AdminSection from '@/components/profile/admin-section'; export default function ProfilePage() { const { user, ready } = useAuth(); - // Account state - const [currentPassword, setCurrentPassword] = useState(''); - const [newPassword, setNewPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [passwordSaving, setPasswordSaving] = useState(false); - const [passwordError, setPasswordError] = useState(null); - const [showCurrentPassword, setShowCurrentPassword] = useState(false); - const [showNewPassword, setShowNewPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const [lastPasswordChange, setLastPasswordChange] = useState(null); - - // Data & Privacy state - const [dispatchLogs, setDispatchLogs] = useState([]); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [deletePassword, setDeletePassword] = useState(''); - const [deleting, setDeleting] = useState(false); - - // Preferences state - - // General state - const [activeSection, setActiveSection] = useState('account'); - const [message, setMessage] = useState(null); - - useEffect(() => { - if (!ready || !user) return; - - // Load user data - const loadUserData = async () => { - try { - const userDoc = await dbService.getDocument>('users', user.uid); - if (userDoc.exists && userDoc.data) { - const raw = userDoc.data.lastPasswordChange; - // Handle Firestore Timestamp (.toDate()), Date, or ISO string - const date = - raw && typeof (raw as { toDate?: () => Date }).toDate === 'function' - ? (raw as { toDate: () => Date }).toDate() - : raw instanceof Date - ? raw - : raw - ? new Date(raw as string) - : null; - setLastPasswordChange(date); - } - } catch (err) { - console.error('Error loading user data:', err); - } - }; - - // Load dispatch logs - const loadDispatchLogs = async () => { - try { - const logs = await dbService.queryCollection('dispatchLogs', [ - { field: 'userId', op: '==', value: user.uid }, - ]); - setDispatchLogs(logs.map((snap) => ({ id: snap.id, ...(snap.data ?? {}) }))); - } catch (err) { - console.error('Error loading dispatch logs:', err); - } - }; - - loadUserData(); - loadDispatchLogs(); - }, [user, ready]); - if (!ready) return ; if (!user) return
You are not signed in.
; - const handleChangePassword = async () => { - if (!authService.currentUser) return setMessage('Not signed in'); - setPasswordError(null); - if (!currentPassword) { - setPasswordError('Enter your current password'); - return; - } - if (!newPassword) { - setPasswordError('Enter a new password'); - return; - } - if (newPassword !== confirmPassword) { - setPasswordError('New passwords do not match'); - return; - } - - setPasswordSaving(true); - setMessage(null); - try { - await authService.updatePassword(currentPassword, newPassword); - - // Update last password change - const uid = authService.currentUser!.uid; - await dbService.setDocument('users', uid, { lastPasswordChange: new Date() }, { merge: true }); - setLastPasswordChange(new Date()); - - setMessage('Password updated successfully.'); - setCurrentPassword(''); - setNewPassword(''); - setConfirmPassword(''); - setPasswordError(null); - } catch (err) { - if (err instanceof ServiceError) { - const code = err.code; - const message = err.message; - if (code === 'auth/wrong-password' || /wrong-password|invalid-credential/i.test(message)) { - setPasswordError('Current password is incorrect'); - setMessage(null); - } else { - setPasswordError(null); - setMessage(message || 'Failed to update password'); - } - } else { - setPasswordError(null); - setMessage(err instanceof Error ? err.message : 'Failed to update password'); - } - } finally { - setPasswordSaving(false); - } - }; - - const handleSignOut = async () => { - try { - await authService.signOut(); - } catch (err) { - setMessage(err instanceof Error ? err.message : 'Failed to sign out'); - } - }; - - const handleDeleteAccount = async () => { - if (!authService.currentUser || !deletePassword) { - setMessage('Enter your password to confirm deletion'); - return; - } - - setDeleting(true); - setMessage(null); - try { - // Delete user document first, then delete the auth account - await dbService.deleteDocument('users', authService.currentUser.uid); - await authService.deleteCurrentUser(deletePassword); - } catch (err) { - if (err instanceof ServiceError) { - const code = err.code; - const message = err.message; - if (code === 'auth/wrong-password' || /wrong-password|invalid-credential/i.test(message)) { - setMessage('Incorrect password'); - } else { - setMessage(message || 'Failed to delete account'); - } - } else { - setMessage(err instanceof Error ? err.message : 'Failed to delete account'); - } - } finally { - setDeleting(false); - setShowDeleteConfirm(false); - setDeletePassword(''); - } - }; - - const handleExportData = () => { - const data = { - user: user, - dispatchLogs: dispatchLogs, - }; - const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'crowdcad-data.json'; - a.click(); - URL.revokeObjectURL(url); - }; - return ( -
+
-
-
- setActiveSection(key as string)} - classNames={{ - base: "h-full", - tabList: "sticky top-0 h-full w-64 border-r border-default-200 bg-transparent p-4", - tab: "justify-start h-12 text-base", - panel: "h-full flex-1 overflow-y-auto p-6 w-full" - }} - > - - - Account -
- } - > -
-

Account

- - - -

Profile Information

-
- -
-

{user.displayName || 'No display name'}

-

{user.email}

-
-
-
-
- - - -

Security

-
-
-
- -
- setCurrentPassword(e.target.value)} - placeholder="Enter current password" - classNames={{ - inputWrapper: "rounded-2xl px-4 hover:bg-surface-deep", - input: "text-surface-light outline-none focus:outline-none data-[focus=true]:outline-none", - }} - endContent={ - - } - /> -
-
-
-
- -
- setNewPassword(e.target.value)} - placeholder="Enter new password" - classNames={{ - inputWrapper: "rounded-2xl px-4 hover:bg-surface-deep", - input: "text-surface-light outline-none focus:outline-none data-[focus=true]:outline-none", - }} - endContent={ - - } - /> -
-
-
- -
- setConfirmPassword(e.target.value)} - placeholder="Confirm new password" - classNames={{ - inputWrapper: "rounded-2xl px-4 hover:bg-surface-deep", - input: "text-surface-light outline-none focus:outline-none data-[focus=true]:outline-none", - }} - endContent={ - - } - /> -
-
-
-
- -
-
- {passwordError &&

{passwordError}

} - {lastPasswordChange && ( -

- Last changed: {lastPasswordChange.toLocaleDateString()} -

- )} -
-
-
- - - -

Session

- -
-
-
- - - - - Affiliations & Access -
- } - > -
-

Affiliations & Access

- - - -

Organization features are currently unavailable.

-
-
-
- - - - - Data & Privacy - - } - > -
-

Data & Privacy

- - - -

Your Data

-
-
-

Dispatch Logs

-

{dispatchLogs.length} entries

-
- -
-
-
- - - -
-
-

Delete Account

-

- This will permanently delete your account and all associated data. -

- -
-
-
-
-
-
- - - - Preferences - - } - > -
-

Preferences

- - -

Preferences configuration coming soon...

-
-
-
-
- - +
+ + + +
- - {/* Message Toast */} - {message && ( -
-

{message}

-
- )} - - {/* Delete Account Modal */} - {showDeleteConfirm && ( -
- - -

Delete Account

-

- This action cannot be undone. All your data will be permanently deleted. -

-

Enter your password to confirm:

- setDeletePassword(e.target.value)} - placeholder="Your password" - /> -
- - -
- {message &&

{message}

} -
-
-
- )}
); } diff --git a/src/app/reset-password/page.tsx b/src/app/reset-password/page.tsx new file mode 100644 index 00000000..14a05f65 --- /dev/null +++ b/src/app/reset-password/page.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { Suspense, useState } from 'react'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { Card, CardBody, Button, Input } from '@heroui/react'; +import { authService, ServiceError } from '@/lib/services'; +import { DiagonalStreaksFixed } from '@/components/ui/diagonal-streaks-fixed'; + +const inputClassNames = { + label: 'text-surface-light font-medium', + input: 'text-surface-light outline-none focus:outline-none data-[focus=true]:outline-none focus:ring-0 focus-visible:ring-0', + inputWrapper: 'rounded-2xl px-4 hover:bg-surface-deep', +} as const; + +const cardProps = { + isBlurred: true, + className: 'border border-default-200', + style: { backgroundColor: 'hsl(var(--surface-bg-2) / 0.5)' }, +} as const; + +function ResetPasswordForm() { + const searchParams = useSearchParams(); + const code = searchParams.get('oobCode') || searchParams.get('token'); + + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + if (!code) return; + if (!newPassword) { + setError('Enter a new password'); + return; + } + if (newPassword !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + setSaving(true); + try { + await authService.confirmPasswordReset(code, newPassword); + setDone(true); + } catch (err) { + setError( + err instanceof ServiceError + ? err.message + : 'Failed to reset password. The link may have expired — request a new one.', + ); + } finally { + setSaving(false); + } + }; + + if (!code) { + return ( + + +

Invalid Reset Link

+

+ This password reset link is invalid or has expired. Request a new one from the sign-in screen. +

+ + Back to sign in + +
+
+ ); + } + + if (done) { + return ( + + +

Password Reset

+

Your password has been updated. You can now sign in with it.

+ + Back to sign in + +
+
+ ); + } + + return ( + + + +

Reset Password

+

Choose a new password for your account.

+ +
+ setNewPassword(e.target.value)} + placeholder="Enter new password" + classNames={inputClassNames} + /> + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + classNames={inputClassNames} + /> +
+ + {error &&

{error}

} + +
+ +
+
+
+ + ); +} + +export default function ResetPasswordPage() { + return ( +
+ +
+ + + +
+
+ ); +} diff --git a/src/components/modals/auth/loginmodal.tsx b/src/components/modals/auth/loginmodal.tsx index b6875a2e..3296e6e0 100644 --- a/src/components/modals/auth/loginmodal.tsx +++ b/src/components/modals/auth/loginmodal.tsx @@ -34,6 +34,9 @@ export default function LoginModal({ const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [submitting, setSubmitting] = useState(false); + const [showForgotPassword, setShowForgotPassword] = useState(false); + const [resetSubmitting, setResetSubmitting] = useState(false); + const [resetMessage, setResetMessage] = useState(null); // Reset state when modal opens/closes useEffect(() => { @@ -44,6 +47,8 @@ export default function LoginModal({ setEmail(''); setPassword(''); setConfirmPassword(''); + setShowForgotPassword(false); + setResetMessage(null); } }, [open, initialError]); @@ -52,6 +57,8 @@ export default function LoginModal({ setError(null); setPassword(''); setConfirmPassword(''); + setShowForgotPassword(false); + setResetMessage(null); }, [mode]); const handleSubmit = async () => { @@ -92,6 +99,21 @@ export default function LoginModal({ } }; + const handleSendResetEmail = async () => { + if (!email || resetSubmitting) return; + setResetSubmitting(true); + setResetMessage(null); + try { + await authService.sendPasswordResetEmail(email, `${window.location.origin}/reset-password`); + } catch { + // Deliberately swallowed — always show the same generic message below + // so this form can't be used to enumerate which emails have accounts. + } finally { + setResetSubmitting(false); + setResetMessage('If an account exists for that email, a reset link has been sent.'); + } + }; + const isSubmitDisabled = submitting || !email || @@ -126,87 +148,137 @@ export default function LoginModal({ {(close) => ( <> - {mode === 'login' ? 'Login' : 'Create an Account'} + {showForgotPassword ? 'Reset Password' : mode === 'login' ? 'Login' : 'Create an Account'} - - - - { - if (e.key === 'Enter' && mode === 'login') handleSubmit(); - }} - /> + {showForgotPassword ? ( + +

+ Enter your account email and we'll send you a link to reset your password. +

+ { + if (e.key === 'Enter') handleSendResetEmail(); + }} + /> + + {resetMessage && ( +

{resetMessage}

+ )} + +

+ +

+
+ ) : ( + + - {mode === 'signup' && ( { - if (e.key === 'Enter') handleSubmit(); + if (e.key === 'Enter' && mode === 'login') handleSubmit(); }} /> - )} - {error && ( -

{error}

- )} + {mode === 'signup' && ( + { + if (e.key === 'Enter') handleSubmit(); + }} + /> + )} -

- {mode === 'login' ? ( - <> - Don't have an account?{' '} - - - ) : ( - <> - Already have an account?{' '} - - + {mode === 'login' && ( + )} -

-
+ + {error && ( +

{error}

+ )} + +

+ {mode === 'login' ? ( + <> + Don't have an account?{' '} + + + ) : ( + <> + Already have an account?{' '} + + + )} +

+
+ )} + {showForgotPassword ? ( + + ) : ( + )} )} diff --git a/src/components/profile/admin-certifications-section.tsx b/src/components/profile/admin-certifications-section.tsx new file mode 100644 index 00000000..e1af126a --- /dev/null +++ b/src/components/profile/admin-certifications-section.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useState } from 'react'; +import { Button, Card, CardBody, Chip, Input, Spinner } from '@heroui/react'; +import { useCertifications } from '@/hooks/useCertifications'; +import { DEFAULT_CERTIFICATIONS } from '@/lib/certificationsService'; + +export default function AdminCertificationsSection() { + const { certifications, loading, save } = useCertifications(); + const [newCert, setNewCert] = useState(''); + const [saving, setSaving] = useState(false); + + const handleDelete = async (cert: string) => { + setSaving(true); + try { + await save(certifications.filter((c) => c !== cert)); + } finally { + setSaving(false); + } + }; + + const handleAdd = async () => { + const trimmed = newCert.trim(); + if (!trimmed || certifications.includes(trimmed)) return; + setSaving(true); + try { + await save([...certifications, trimmed]); + setNewCert(''); + } finally { + setSaving(false); + } + }; + + const handleReset = async () => { + setSaving(true); + try { + await save(DEFAULT_CERTIFICATIONS); + } finally { + setSaving(false); + } + }; + + return ( + + +
+

Certifications

+ +
+

+ Certifications available when adding team members to an event. +

+ + {loading ? ( + + ) : ( +
+ {certifications.map((cert) => ( + handleDelete(cert)} + > + {cert} + + ))} + {certifications.length === 0 && ( +

No certifications configured.

+ )} +
+ )} + +
+ setNewCert(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAdd(); + } + }} + placeholder="Add certification" + classNames={{ + inputWrapper: 'rounded-2xl px-4 hover:bg-surface-deep', + input: 'text-surface-light outline-none focus:outline-none data-[focus=true]:outline-none', + }} + /> + +
+
+
+ ); +} diff --git a/src/components/profile/admin-section.tsx b/src/components/profile/admin-section.tsx new file mode 100644 index 00000000..eafe88a4 --- /dev/null +++ b/src/components/profile/admin-section.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { useAdmin } from '@/hooks/useAdmin'; +import type { ServiceUser } from '@/lib/services'; +import AdminCertificationsSection from './admin-certifications-section'; +import AdminUsersSection from './admin-users-section'; + +export default function AdminSection({ currentUser }: { currentUser: ServiceUser }) { + const { isAdmin, loading } = useAdmin(); + + if (loading || !isAdmin) return null; + + return ( +
+

Admin

+ + +
+ ); +} diff --git a/src/components/profile/admin-users-section.tsx b/src/components/profile/admin-users-section.tsx new file mode 100644 index 00000000..918779c9 --- /dev/null +++ b/src/components/profile/admin-users-section.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Button, Card, CardBody, Input, Spinner, Switch } from '@heroui/react'; +import { RefreshCw } from 'lucide-react'; +import { dbService } from '@/lib/services'; +import type { UserDoc } from '@/lib/userDoc'; +import type { ServiceUser } from '@/lib/services'; + +interface UserRow { + id: string; + email: string; + displayName: string; + isAdmin: boolean; +} + +function toRow(id: string, data: UserDoc & Record): UserRow { + return { + id, + email: (data.email as string) || '(no email on record)', + displayName: (data.displayName as string) || (data.name as string) || '', + isAdmin: Boolean(data.isAdmin), + }; +} + +export default function AdminUsersSection({ currentUser }: { currentUser: ServiceUser }) { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [updatingId, setUpdatingId] = useState(null); + + const load = async () => { + setLoading(true); + try { + const docs = await dbService.getCollection>('users'); + setRows(docs.map((d) => toRow(d.id, d.data ?? {}))); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return rows; + return rows.filter( + (r) => r.email.toLowerCase().includes(q) || r.displayName.toLowerCase().includes(q), + ); + }, [rows, search]); + + const toggleAdmin = async (row: UserRow) => { + setUpdatingId(row.id); + try { + await dbService.setDocument('users', row.id, { isAdmin: !row.isAdmin }, { merge: true }); + setRows((prev) => prev.map((r) => (r.id === row.id ? { ...r, isAdmin: !r.isAdmin } : r))); + } finally { + setUpdatingId(null); + } + }; + + return ( + + +
+

Manage Admins

+ +
+ + setSearch(e.target.value)} + placeholder="Search by name or email" + classNames={{ + inputWrapper: 'rounded-2xl px-4 hover:bg-surface-deep', + input: 'text-surface-light outline-none focus:outline-none data-[focus=true]:outline-none', + }} + className="mb-4" + /> + + {loading ? ( + + ) : ( +
+ {filtered.map((row) => ( +
+
+

{row.displayName || row.email}

+ {row.displayName &&

{row.email}

} +
+
+ {row.id === currentUser.uid && (you)} + toggleAdmin(row)} + isDisabled={updatingId === row.id} + aria-label={`Admin access for ${row.email}`} + /> +
+
+ ))} + {filtered.length === 0 &&

No users found.

} +
+ )} +
+
+ ); +} diff --git a/src/components/profile/preferences-section.tsx b/src/components/profile/preferences-section.tsx new file mode 100644 index 00000000..03014f57 --- /dev/null +++ b/src/components/profile/preferences-section.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { Card, CardBody, Switch } from '@heroui/react'; +import { useReducedMotion } from '@/hooks/useReducedMotion'; + +export default function PreferencesSection() { + const { enabled, setEnabled } = useReducedMotion(); + + return ( +
+

Preferences

+ + + +
+
+

Reduce motion

+

+ Minimize animations and transitions throughout the app. +

+
+ +
+
+
+
+ ); +} diff --git a/src/components/profile/profile-info-section.tsx b/src/components/profile/profile-info-section.tsx new file mode 100644 index 00000000..cd744ef4 --- /dev/null +++ b/src/components/profile/profile-info-section.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { Avatar, Button, Card, CardBody } from '@heroui/react'; +import { useRouter } from 'next/navigation'; +import type { ServiceUser } from '@/lib/services'; + +export default function ProfileInfoSection({ user }: { user: ServiceUser }) { + const router = useRouter(); + + return ( + + +
+
+ +
+

{user.displayName || 'No display name'}

+

{user.email}

+
+
+ +
+
+
+ ); +} diff --git a/src/components/profile/security-section.tsx b/src/components/profile/security-section.tsx new file mode 100644 index 00000000..05ef7a89 --- /dev/null +++ b/src/components/profile/security-section.tsx @@ -0,0 +1,353 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Button, Card, CardBody, Input } from '@heroui/react'; +import { authService, dbService, ServiceError, type ServiceUser } from '@/lib/services'; +import { Eye, EyeOff, LogOut, Trash2, Download } from 'lucide-react'; + +export default function SecuritySection({ user }: { user: ServiceUser }) { + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordSaving, setPasswordSaving] = useState(false); + const [passwordError, setPasswordError] = useState(null); + const [showCurrentPassword, setShowCurrentPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [lastPasswordChange, setLastPasswordChange] = useState(null); + const [showPasswordForm, setShowPasswordForm] = useState(false); + + const [dispatchLogs, setDispatchLogs] = useState([]); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [deletePassword, setDeletePassword] = useState(''); + const [deleting, setDeleting] = useState(false); + + const [message, setMessage] = useState(null); + + useEffect(() => { + const loadUserData = async () => { + try { + const userDoc = await dbService.getDocument>('users', user.uid); + if (userDoc.exists && userDoc.data) { + const raw = userDoc.data.lastPasswordChange; + const date = + raw && typeof (raw as { toDate?: () => Date }).toDate === 'function' + ? (raw as { toDate: () => Date }).toDate() + : raw instanceof Date + ? raw + : raw + ? new Date(raw as string) + : null; + setLastPasswordChange(date); + } + } catch (err) { + console.error('Error loading user data:', err); + } + }; + + const loadDispatchLogs = async () => { + try { + const logs = await dbService.queryCollection('dispatchLogs', [ + { field: 'userId', op: '==', value: user.uid }, + ]); + setDispatchLogs(logs.map((snap) => ({ id: snap.id, ...(snap.data ?? {}) }))); + } catch (err) { + console.error('Error loading dispatch logs:', err); + } + }; + + loadUserData(); + loadDispatchLogs(); + }, [user.uid]); + + const handleChangePassword = async () => { + if (!authService.currentUser) return setMessage('Not signed in'); + setPasswordError(null); + if (!currentPassword) { + setPasswordError('Enter your current password'); + return; + } + if (!newPassword) { + setPasswordError('Enter a new password'); + return; + } + if (newPassword !== confirmPassword) { + setPasswordError('New passwords do not match'); + return; + } + + setPasswordSaving(true); + setMessage(null); + try { + await authService.updatePassword(currentPassword, newPassword); + + const uid = authService.currentUser!.uid; + await dbService.setDocument('users', uid, { lastPasswordChange: new Date() }, { merge: true }); + setLastPasswordChange(new Date()); + + setMessage('Password updated successfully.'); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setPasswordError(null); + setShowPasswordForm(false); + } catch (err) { + if (err instanceof ServiceError) { + const code = err.code; + const errMessage = err.message; + if (code === 'auth/wrong-password' || /wrong-password|invalid-credential/i.test(errMessage)) { + setPasswordError('Current password is incorrect'); + setMessage(null); + } else { + setPasswordError(null); + setMessage(errMessage || 'Failed to update password'); + } + } else { + setPasswordError(null); + setMessage(err instanceof Error ? err.message : 'Failed to update password'); + } + } finally { + setPasswordSaving(false); + } + }; + + const handleCancelPasswordForm = () => { + setShowPasswordForm(false); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setPasswordError(null); + }; + + const handleSignOut = async () => { + try { + await authService.signOut(); + } catch (err) { + setMessage(err instanceof Error ? err.message : 'Failed to sign out'); + } + }; + + const handleDeleteAccount = async () => { + if (!authService.currentUser || !deletePassword) { + setMessage('Enter your password to confirm deletion'); + return; + } + + setDeleting(true); + setMessage(null); + try { + await dbService.deleteDocument('users', authService.currentUser.uid); + await authService.deleteCurrentUser(deletePassword); + } catch (err) { + if (err instanceof ServiceError) { + const code = err.code; + const errMessage = err.message; + if (code === 'auth/wrong-password' || /wrong-password|invalid-credential/i.test(errMessage)) { + setMessage('Incorrect password'); + } else { + setMessage(errMessage || 'Failed to delete account'); + } + } else { + setMessage(err instanceof Error ? err.message : 'Failed to delete account'); + } + } finally { + setDeleting(false); + setShowDeleteConfirm(false); + setDeletePassword(''); + } + }; + + const handleExportData = () => { + const data = { user, dispatchLogs }; + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'crowdcad-data.json'; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+

Security

+ + + +
+
+

Password

+ {lastPasswordChange && ( +

+ Last changed: {lastPasswordChange.toLocaleDateString()} +

+ )} +
+ {!showPasswordForm && ( + + )} +
+ + {showPasswordForm && ( +
+
+
+ + setCurrentPassword(e.target.value)} + placeholder="Enter current password" + classNames={{ + inputWrapper: 'rounded-2xl px-4 hover:bg-surface-deep', + input: 'text-surface-light outline-none focus:outline-none data-[focus=true]:outline-none', + }} + endContent={ + + } + /> +
+
+ + setNewPassword(e.target.value)} + placeholder="Enter new password" + classNames={{ + inputWrapper: 'rounded-2xl px-4 hover:bg-surface-deep', + input: 'text-surface-light outline-none focus:outline-none data-[focus=true]:outline-none', + }} + endContent={ + + } + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + classNames={{ + inputWrapper: 'rounded-2xl px-4 hover:bg-surface-deep', + input: 'text-surface-light outline-none focus:outline-none data-[focus=true]:outline-none', + }} + endContent={ + + } + /> +
+
+
+ + +
+ {passwordError &&

{passwordError}

} +
+ )} +
+
+ + + +
+
+

Data & Account

+

{dispatchLogs.length} dispatch log entries

+
+
+ + + +
+
+
+
+ + {message && ( +
+

{message}

+
+ )} + + {showDeleteConfirm && ( +
+ + +

Delete Account

+

+ This action cannot be undone. All your data will be permanently deleted. +

+

Enter your password to confirm:

+ setDeletePassword(e.target.value)} + placeholder="Your password" + /> +
+ + +
+ {message &&

{message}

} +
+
+
+ )} +
+ ); +} diff --git a/src/hooks/useAdmin.ts b/src/hooks/useAdmin.ts new file mode 100644 index 00000000..ac218a08 --- /dev/null +++ b/src/hooks/useAdmin.ts @@ -0,0 +1,41 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { useAuth } from './useauth'; +import { dbService } from '@/lib/services'; +import type { UserDoc } from '@/lib/userDoc'; + +export function useAdmin() { + const { user, ready } = useAuth(); + const [isAdmin, setIsAdmin] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!ready) return; + if (!user) { + setIsAdmin(false); + setLoading(false); + return; + } + + let cancelled = false; + setLoading(true); + dbService + .getDocument('users', user.uid) + .then((snap) => { + if (cancelled) return; + setIsAdmin(Boolean(snap.exists && snap.data?.isAdmin)); + }) + .catch(() => { + if (!cancelled) setIsAdmin(false); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [user, ready]); + + return { isAdmin, loading }; +} diff --git a/src/hooks/useCertifications.ts b/src/hooks/useCertifications.ts new file mode 100644 index 00000000..8fabe9eb --- /dev/null +++ b/src/hooks/useCertifications.ts @@ -0,0 +1,28 @@ +'use client'; +import { useCallback, useEffect, useState } from 'react'; +import { getCertifications, setCertifications, DEFAULT_CERTIFICATIONS } from '@/lib/certificationsService'; + +export function useCertifications() { + const [certifications, setCertificationsState] = useState(DEFAULT_CERTIFICATIONS); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + setLoading(true); + try { + setCertificationsState(await getCertifications()); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const save = useCallback(async (list: string[]) => { + await setCertifications(list); + setCertificationsState(list); + }, []); + + return { certifications, loading, save, refresh }; +} diff --git a/src/hooks/useReducedMotion.ts b/src/hooks/useReducedMotion.ts new file mode 100644 index 00000000..63810bfb --- /dev/null +++ b/src/hooks/useReducedMotion.ts @@ -0,0 +1,34 @@ +'use client'; +import { useCallback, useEffect, useState } from 'react'; + +const STORAGE_KEY = 'ccad-reduced-motion'; + +function applyReducedMotionClass(enabled: boolean) { + const root = document.documentElement; + root.classList.toggle('reduce-motion', enabled); + root.setAttribute('data-reduced-motion', String(enabled)); +} + +export function useReducedMotion() { + const [enabled, setEnabledState] = useState(false); + + useEffect(() => { + try { + setEnabledState(localStorage.getItem(STORAGE_KEY) === '1'); + } catch { + // localStorage unavailable — keep default + } + }, []); + + const setEnabled = useCallback((value: boolean) => { + setEnabledState(value); + applyReducedMotionClass(value); + try { + localStorage.setItem(STORAGE_KEY, value ? '1' : '0'); + } catch { + // localStorage unavailable — in-memory state still updates + } + }, []); + + return { enabled, setEnabled }; +} diff --git a/src/hooks/useauth.ts b/src/hooks/useauth.ts index b596438f..07c13888 100644 --- a/src/hooks/useauth.ts +++ b/src/hooks/useauth.ts @@ -1,6 +1,11 @@ 'use client'; import { useEffect, useState } from 'react'; -import { authService, type ServiceUser } from '@/lib/services'; +import { authService, dbService, isPocketbaseBackend, type ServiceUser } from '@/lib/services'; + +// Tracks which uids have already had their `users/{uid}` doc synced with +// email/displayName this session, so the merge write below only fires once +// per user even though many components mount useAuth() independently. +const syncedUids = new Set(); export function useAuth() { const [user, setUser] = useState(null); @@ -10,6 +15,21 @@ export function useAuth() { const unsub = authService.onAuthStateChanged((u) => { setUser(u); setReady(true); + + // Best-effort sync so the users collection has enough info (email, + // displayName) for the admin "Manage Admins" lookup — the doc is + // otherwise only ever written with partial fields (phoneNumber, etc). + // PocketBase's `users` collection is the built-in auth collection and + // already has these as native fields — writing `email` there could + // trigger its email-change/verification flow, so skip it there. + if (u && !isPocketbaseBackend && !syncedUids.has(u.uid)) { + syncedUids.add(u.uid); + dbService + .setDocument('users', u.uid, { email: u.email, displayName: u.displayName }, { merge: true }) + .catch(() => { + syncedUids.delete(u.uid); + }); + } }); return () => unsub(); }, []); diff --git a/src/lib/certificationsService.ts b/src/lib/certificationsService.ts new file mode 100644 index 00000000..62ce7b98 --- /dev/null +++ b/src/lib/certificationsService.ts @@ -0,0 +1,38 @@ +import { dbService } from '@/lib/services'; + +export const DEFAULT_CERTIFICATIONS = ['CPR', 'EMT-B', 'EMT-A', 'EMT-P', 'RN', 'MD/DO']; + +const SETTINGS_COLLECTION = 'settings'; +const CERTIFICATIONS_KEY = 'certifications'; + +interface CertificationsDoc { + key: string; + list: string[]; +} + +async function findCertificationsDoc() { + const docs = await dbService.queryCollection(SETTINGS_COLLECTION, [ + { field: 'key', op: '==', value: CERTIFICATIONS_KEY }, + ]); + return docs[0] ?? null; +} + +export async function getCertifications(): Promise { + const doc = await findCertificationsDoc(); + if (doc?.data?.list?.length) return doc.data.list; + return DEFAULT_CERTIFICATIONS; +} + +export async function setCertifications(list: string[]): Promise { + const doc = await findCertificationsDoc(); + if (doc) { + await dbService.setDocument( + SETTINGS_COLLECTION, + doc.id, + { key: CERTIFICATIONS_KEY, list }, + { merge: true }, + ); + } else { + await dbService.addDocument(SETTINGS_COLLECTION, { key: CERTIFICATIONS_KEY, list }); + } +} diff --git a/src/lib/services/IAuthService.ts b/src/lib/services/IAuthService.ts index 78e2ace9..f6f74353 100644 --- a/src/lib/services/IAuthService.ts +++ b/src/lib/services/IAuthService.ts @@ -22,6 +22,17 @@ export interface IAuthService { /** Re-authenticates with password before permanently deleting the account. */ deleteCurrentUser(password: string): Promise; + /** + * Sends a password-reset email for an account that's locked out (doesn't + * know its current password). `actionUrl` (Firebase only) is where the + * reset link in the email points back into this app; PocketBase's link + * destination is controlled by its own email template instead. + */ + sendPasswordResetEmail(email: string, actionUrl?: string): Promise; + + /** Completes a password reset using the code/token pulled from the reset-link URL. */ + confirmPasswordReset(code: string, newPassword: string): Promise; + /** Synchronous access to the current user (null if not signed in or not yet resolved). */ readonly currentUser: ServiceUser | null; } diff --git a/src/lib/services/firebase/FirebaseAuthService.ts b/src/lib/services/firebase/FirebaseAuthService.ts index fdf5fd84..7c5d9234 100644 --- a/src/lib/services/firebase/FirebaseAuthService.ts +++ b/src/lib/services/firebase/FirebaseAuthService.ts @@ -8,6 +8,8 @@ import { reauthenticateWithCredential, EmailAuthProvider, deleteUser, + sendPasswordResetEmail as firebaseSendPasswordResetEmail, + confirmPasswordReset as firebaseConfirmPasswordReset, type User, } from 'firebase/auth'; import { auth } from '@/app/firebase'; @@ -102,4 +104,24 @@ export class FirebaseAuthService implements IAuthService { throw toServiceError(err); } } + + async sendPasswordResetEmail(email: string, actionUrl?: string): Promise { + try { + await firebaseSendPasswordResetEmail( + auth, + email, + actionUrl ? { url: actionUrl, handleCodeInApp: true } : undefined, + ); + } catch (err) { + throw toServiceError(err); + } + } + + async confirmPasswordReset(code: string, newPassword: string): Promise { + try { + await firebaseConfirmPasswordReset(auth, code, newPassword); + } catch (err) { + throw toServiceError(err); + } + } } diff --git a/src/lib/services/pocketbase/PocketbaseAuthService.ts b/src/lib/services/pocketbase/PocketbaseAuthService.ts index 21e9459f..cadfc5f5 100644 --- a/src/lib/services/pocketbase/PocketbaseAuthService.ts +++ b/src/lib/services/pocketbase/PocketbaseAuthService.ts @@ -117,4 +117,23 @@ export class PocketbaseAuthService implements IAuthService { throw toServiceError(err); } } + + async sendPasswordResetEmail(email: string): Promise { + // actionUrl is ignored — PocketBase's reset-link destination is set via + // its own email template (Admin UI > Settings > Mail templates), not + // per-request. + try { + await pb.collection('users').requestPasswordReset(email); + } catch (err) { + throw toServiceError(err); + } + } + + async confirmPasswordReset(code: string, newPassword: string): Promise { + try { + await pb.collection('users').confirmPasswordReset(code, newPassword, newPassword); + } catch (err) { + throw toServiceError(err); + } + } } diff --git a/src/lib/userDoc.ts b/src/lib/userDoc.ts new file mode 100644 index 00000000..a579aee5 --- /dev/null +++ b/src/lib/userDoc.ts @@ -0,0 +1,8 @@ +/** Shape of a document in the `users` collection (keyed by uid), as read/written ad hoc across the app. */ +export interface UserDoc { + email?: string | null; + displayName?: string | null; + phoneNumber?: string | null; + lastPasswordChange?: unknown; + isAdmin?: boolean; +} diff --git a/tests/e2e/features/auth-modal.feature b/tests/e2e/features/auth-modal.feature index 9557822b..e012a338 100644 --- a/tests/e2e/features/auth-modal.feature +++ b/tests/e2e/features/auth-modal.feature @@ -91,3 +91,30 @@ Feature: Login modal And I fill the "Confirm Password" field with "SecurePass123!" And I click the "Sign Up" submit button Then the login modal should not be visible + + Scenario: Forgot password link is only shown in login mode + When I click the "Sign In" button + Then I should see a "Forgot password?" button in the modal + When I click the "Sign Up" button in the modal + Then the "Forgot password?" button should not be visible in the modal + + Scenario: Forgot password link switches to the reset view + When I click the "Sign In" button + And I click the "Forgot password?" button in the modal + Then the login modal should have the title "Reset Password" + And the "Password" field should not be visible in the modal + And the "Email Address" field should be visible in the modal + + Scenario: Requesting a reset link shows the same generic message for any email + When I click the "Sign In" button + And I click the "Forgot password?" button in the modal + And I fill the "Email Address" field with "no-such-account@crowdcad.test" + And I click the "Send Reset Link" button in the modal + Then I should see the modal text "If an account exists for that email, a reset link has been sent." + + Scenario: Back to login returns from the reset view + When I click the "Sign In" button + And I click the "Forgot password?" button in the modal + And I click the "Back to login" button in the modal + Then the login modal should have the title "Login" + And the "Password" field should be visible in the modal diff --git a/tests/e2e/features/navigation.feature b/tests/e2e/features/navigation.feature index 857a551b..7dc320f8 100644 --- a/tests/e2e/features/navigation.feature +++ b/tests/e2e/features/navigation.feature @@ -21,7 +21,7 @@ Feature: Main navigation Scenario: Profile page loads for authenticated user Given I navigate to "/profile" - Then I should see the "Account" tab + Then I should see the heading "Security" Scenario: Authenticated landing page shows Start a New Event Given I navigate to "/" diff --git a/tests/e2e/features/profile-edit.feature b/tests/e2e/features/profile-edit.feature index d41d8287..1d59137b 100644 --- a/tests/e2e/features/profile-edit.feature +++ b/tests/e2e/features/profile-edit.feature @@ -8,7 +8,7 @@ Feature: Profile edit page Scenario: Edit profile page renders form fields Then I should see the heading "Edit Profile" And I should see the "Your full name" placeholder - And I should see the "https://..." placeholder + And I should see the "+1 555 555 5555" placeholder Scenario: Save and Cancel buttons are visible Then I should see a "Save Changes" button diff --git a/tests/e2e/features/profile.feature b/tests/e2e/features/profile.feature index c5d37132..77957da1 100644 --- a/tests/e2e/features/profile.feature +++ b/tests/e2e/features/profile.feature @@ -6,43 +6,37 @@ Feature: Profile page Given I navigate to "/profile" And the profile page is loaded - Scenario: All four vertical tabs are shown - Then I should see the "Account" tab - And I should see the "Affiliations & Access" tab - And I should see the "Data & Privacy" tab - And I should see the "Preferences" tab + Scenario: Profile info section shows the logged in user email + Then I should see the logged in user email - Scenario: Account tab is selected by default and shows user email - Then the "Account" tab should be selected - And I should see the logged in user email + Scenario: Edit Profile button navigates to the edit page + When I click the "Edit Profile" button + Then the URL should be "/profile/edit" - Scenario: Account tab shows Security section with password fields + Scenario: Password fields are collapsed behind a Change Password button by default Then I should see the heading "Security" - And I should see the "Enter current password" placeholder + And I should see a "Change Password" button + And I should not see the text "Enter current password" + + Scenario: Change Password button reveals the password fields + When I click the "Change Password" button + Then I should see the "Enter current password" placeholder And I should see the "Enter new password" placeholder And I should see the "Confirm new password" placeholder And I should see an "Update Password" button - Scenario: Account tab shows Sign Out button in Session section - Then I should see the heading "Session" - And I should see a "Sign Out" button - - Scenario: Affiliations & Access tab shows unavailability message - When I click the "Affiliations & Access" tab - Then I should see the heading "Affiliations & Access" - And I should see the text "Organization features are currently unavailable." - - Scenario: Data & Privacy tab shows data section and action buttons - When I click the "Data & Privacy" tab - Then I should see the heading "Data & Privacy" - And I should see the text "Dispatch Logs" - And I should see an "Export Data" button + Scenario: Data & Account section shows export, delete, and sign out actions + Then I should see the heading "Data & Account" + And I should see a "Export Data" button And I should see a "Delete Account" button + And I should see a "Sign Out" button - Scenario: Preferences tab shows coming soon message - When I click the "Preferences" tab + Scenario: Preferences section shows the reduce motion toggle Then I should see the heading "Preferences" - And I should see the text "Preferences configuration coming soon..." + And I should see the text "Reduce motion" + + Scenario: Admin section is not shown to a non-admin user + Then I should not see the text "Manage Admins" Scenario: Profile is accessible via the navbar profile dropdown Given I navigate to "/" @@ -56,25 +50,25 @@ Feature: Profile page Then I should see the text "You are not signed in." Scenario: Password change shows error when passwords do not match - When I fill the "Enter current password" placeholder with "SomePassword123!" + When I click the "Change Password" button + And I fill the "Enter current password" placeholder with "SomePassword123!" And I fill the "Enter new password" placeholder with "NewPass123!" And I fill the "Confirm new password" placeholder with "DifferentPass456!" And I click the "Update Password" button Then I should see the text "New passwords do not match" Scenario: Password change shows error when current password is empty - When I fill the "Enter new password" placeholder with "NewPass123!" + When I click the "Change Password" button + And I fill the "Enter new password" placeholder with "NewPass123!" And I fill the "Confirm new password" placeholder with "NewPass123!" And I click the "Update Password" button Then I should see the text "Enter your current password" Scenario: Delete Account button opens confirmation dialog - When I click the "Data & Privacy" tab - And I click the "Delete Account" button + When I click the "Delete Account" button Then I should see the text "This action cannot be undone" Scenario: Delete Account cancel closes the dialog - When I click the "Data & Privacy" tab - And I click the "Delete Account" button + When I click the "Delete Account" button And I click the "Cancel" button Then I should not see the text "This action cannot be undone" diff --git a/tests/e2e/features/reset-password.feature b/tests/e2e/features/reset-password.feature new file mode 100644 index 00000000..8226dd19 --- /dev/null +++ b/tests/e2e/features/reset-password.feature @@ -0,0 +1,27 @@ +@public +Feature: Reset password page + Tests for the /reset-password page — reached via a "forgot password" email link. + Runs without auth state (logged-out browser). + + Scenario: Visiting without a code shows an invalid link message + Given I navigate to "/reset-password" + Then I should see the heading "Invalid Reset Link" + And I should see the text "This password reset link is invalid or has expired." + + Scenario: Visiting with a code shows the reset form + Given I navigate to "/reset-password?token=some-test-token" + Then I should see the heading "Reset Password" + And I should see the "Enter new password" placeholder + And I should see the "Confirm new password" placeholder + + Scenario: Submitting without a new password shows a validation error + Given I navigate to "/reset-password?token=some-test-token" + When I click the "Reset Password" button + Then I should see the text "Enter a new password" + + Scenario: Submitting mismatched passwords shows a validation error + Given I navigate to "/reset-password?token=some-test-token" + When I fill the "Enter new password" placeholder with "NewPass123!" + And I fill the "Confirm new password" placeholder with "DifferentPass456!" + And I click the "Reset Password" button + Then I should see the text "Passwords do not match" diff --git a/tests/e2e/steps/auth.steps.ts b/tests/e2e/steps/auth.steps.ts index e9c143e3..05287c4e 100644 --- a/tests/e2e/steps/auth.steps.ts +++ b/tests/e2e/steps/auth.steps.ts @@ -43,6 +43,10 @@ Then('I should see a {string} button in the modal', async ({ page }, name: strin await expect(page.getByRole('dialog').getByRole('button', { name })).toBeVisible(); }); +Then('the {string} button should not be visible in the modal', async ({ page }, name: string) => { + await expect(page.getByRole('dialog').getByRole('button', { name })).not.toBeVisible(); +}); + Then('I should see a {string} submit button in the modal', async ({ page }, name: string) => { await expect( page.getByRole('dialog').getByRole('button', { name, exact: true }) diff --git a/tests/e2e/steps/profile.steps.ts b/tests/e2e/steps/profile.steps.ts index 59b8c13a..12b15fe2 100644 --- a/tests/e2e/steps/profile.steps.ts +++ b/tests/e2e/steps/profile.steps.ts @@ -5,5 +5,5 @@ import { test } from '../fixtures'; const { Given } = createBdd(test); Given('the profile page is loaded', async ({ page }) => { - await expect(page.getByRole('tab', { name: 'Account' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Security' })).toBeVisible(); }); From 7d7bddd4894bbd10f62db2d685ed46b65697d536 Mon Sep 17 00:00:00 2001 From: evanqua Date: Tue, 11 Aug 2026 21:57:21 -0700 Subject: [PATCH 02/16] feat: let admins delete other users' accounts and data, add Organization Venues Admins can now delete a user's venues, events, dispatch logs, and profile doc from Profile > Admin > Manage Administrator Access (fully removes the account on PocketBase; on Firebase the Auth record itself has to be removed separately via the console, since the client SDK can't delete another user's auth account without a privileged server credential). Also adds Organization Venues: an admin can mark venues they own as visible to every user on the instance, instead of sharing them with people one at a time. Co-Authored-By: Claude Sonnet 5 --- firestore.rules | 16 ++- scripts/setup-pocketbase.js | 14 ++- src/app/(main)/venues/selection/page.tsx | 28 ++++- src/app/types.ts | 1 + src/components/profile/admin-section.tsx | 2 + .../profile/admin-users-section.tsx | 104 +++++++++++++++++- .../profile/admin-venues-section.tsx | 96 ++++++++++++++++ src/components/profile/security-section.tsx | 2 +- tests/e2e/global-setup.pocketbase.ts | 23 ++++ 9 files changed, 271 insertions(+), 15 deletions(-) create mode 100644 src/components/profile/admin-venues-section.tsx diff --git a/firestore.rules b/firestore.rules index 8b26e4dc..3acb0296 100644 --- a/firestore.rules +++ b/firestore.rules @@ -21,17 +21,29 @@ service cloud.firestore { } // Venues: require org membership to read; writes only by org members. + // Admins may also update/delete any venue (needed to wipe a deleted + // user's data from Profile > Admin > Manage Administrator Access). match /venues/{venueId} { allow read: if isVenueVisibleToUser(venueId); allow create: if request.auth != null && request.resource.data.orgId is string && isOrgMember(request.resource.data.orgId); - allow update, delete: if isVenueOwnerOrOrgMember(venueId); + allow update, delete: if isVenueOwnerOrOrgMember(venueId) || isRequestingUserAdmin(); } // Events: require org membership to read; writes only by org members. + // Admins may also update/delete any event (see venues comment above). match /events/{eventId} { allow read: if isEventVisibleToUser(eventId); allow create: if request.auth != null && request.resource.data.orgId is string && isOrgMember(request.resource.data.orgId); - allow update, delete: if isEventOwnerOrOrgMember(eventId); + allow update, delete: if isEventOwnerOrOrgMember(eventId) || isRequestingUserAdmin(); + } + + // Dispatch logs: owner-scoped, with an admin override for the same + // account-deletion flow. (No prior rule existed for this collection — + // added now since admin-initiated deletes need it to actually work.) + match /dispatchLogs/{logId} { + allow read, update, delete: if request.auth != null && + (resource.data.userId == request.auth.uid || isRequestingUserAdmin()); + allow create: if request.auth != null && request.resource.data.userId == request.auth.uid; } // Users: allow users to read/write their own profile; admins may also diff --git a/scripts/setup-pocketbase.js b/scripts/setup-pocketbase.js index 74201caf..1dfcfbfa 100644 --- a/scripts/setup-pocketbase.js +++ b/scripts/setup-pocketbase.js @@ -146,8 +146,13 @@ async function main() { { name: 'posts', type: 'json' }, { name: 'mapUrl', type: 'text' }, { name: 'sharedWith', type: 'json' }, + { name: 'isOrgVenue', type: 'bool' }, ]); + // `isOrgVenue` on `venues` — set for deployments where this collection + // already existed before the field was added above. + await ensureField(headers, 'venues', { name: 'isOrgVenue', type: 'bool' }); + await ensureCollection(headers, 'events', [ { name: 'name', type: 'text' }, { name: 'date', type: 'text' }, @@ -204,11 +209,14 @@ async function main() { 'Review access rules in the PocketBase admin UI at ' + PB_URL + '/_/ before going to production.', ); console.log( - "\nSecurity: restrict the built-in 'users' collection's List/View/Update rules to\n" + + "\nSecurity: restrict the built-in 'users' collection's List/View/Update/Delete rules to\n" + ' @request.auth.id = id || @request.auth.isAdmin = true\n' + 'in the admin UI (Collections > users > API Rules) — otherwise any authenticated\n' + - "user may be able to list other users or flip their own 'isAdmin' field. This\n" + - "isn't set automatically so it doesn't overwrite rules you've already customized.", + "user may be able to list other users, flip their own 'isAdmin' field, or delete\n" + + "someone else's account. The same rule also keeps self-deletion (Profile > Security)\n" + + "and admin-initiated deletion (Profile > Admin > Manage Administrator Access) working\n" + + "as intended. This isn't set automatically so it doesn't overwrite rules you've\n" + + 'already customized.', ); console.log( "\nForgot-password emails: PocketBase's default reset-password email links to its\n" + diff --git a/src/app/(main)/venues/selection/page.tsx b/src/app/(main)/venues/selection/page.tsx index 7b989387..667dd510 100644 --- a/src/app/(main)/venues/selection/page.tsx +++ b/src/app/(main)/venues/selection/page.tsx @@ -46,6 +46,7 @@ export default function VenueSelection() { const [isMobile, setIsMobile] = useState(false); const [ownedVenuesList, setOwnedVenuesList] = useState([]); const [sharedVenuesList, setSharedVenuesList] = useState([]); + const [orgVenuesList, setOrgVenuesList] = useState([]); const [ownedEventsList, setOwnedEventsList] = useState([]); const [sharedEventsList, setSharedEventsList] = useState([]); const [isStartingEvent, setIsStartingEvent] = useState(false); @@ -166,6 +167,13 @@ export default function VenueSelection() { setSharedVenuesList([]); } + // 2b. Organization Venues Listener — admin-designated venues visible to everyone + listeners.push(dbService.subscribeToQuery( + 'venues', + [{ field: 'isOrgVenue', op: '==', value: true }], + (snaps) => setOrgVenuesList(snaps.map(s => ({ ...s.data, id: s.id } as Venue))), + )); + // 3. Owned Events Listener listeners.push(dbService.subscribeToQuery( 'events', @@ -224,8 +232,8 @@ export default function VenueSelection() { // Combine Venues const venueMap = new Map(); - // Add owned and shared venues to map - [...ownedVenuesList, ...sharedVenuesList].forEach(v => { + // Add owned, shared, and organization venues to map + [...ownedVenuesList, ...sharedVenuesList, ...orgVenuesList].forEach(v => { venueMap.set(v.id, v); }); @@ -255,7 +263,7 @@ export default function VenueSelection() { setVenues(Array.from(venueMap.values())); setRecentEvents(uniqueEvents); - }, [ownedVenuesList, sharedVenuesList, ownedEventsList, sharedEventsList]); + }, [ownedVenuesList, sharedVenuesList, orgVenuesList, ownedEventsList, sharedEventsList]); const venueStats = useMemo(() => { const byVenue: Record = {}; @@ -383,7 +391,12 @@ export default function VenueSelection() {
-

{venue.name}

+

{venue.name}

+ {venue.isOrgVenue && ( + + Org + + )}
{stats.count} {stats.count === 1 ? 'event' : 'events'} @@ -669,7 +682,12 @@ export default function VenueSelection() {
-

{venue.name}

+

{venue.name}

+ {venue.isOrgVenue && ( + + Org + + )}
{stats.count} {stats.count === 1 ? 'event' : 'events'} diff --git a/src/app/types.ts b/src/app/types.ts index f3c93d5b..3730971b 100644 --- a/src/app/types.ts +++ b/src/app/types.ts @@ -28,6 +28,7 @@ export interface Venue { mapUrl?: string; userId: string; sharedWith?: string[]; // Array of emails + isOrgVenue?: boolean; // Visible to all users on this instance, set by an admin } export interface Event { diff --git a/src/components/profile/admin-section.tsx b/src/components/profile/admin-section.tsx index eafe88a4..0a5fc5ad 100644 --- a/src/components/profile/admin-section.tsx +++ b/src/components/profile/admin-section.tsx @@ -4,6 +4,7 @@ import { useAdmin } from '@/hooks/useAdmin'; import type { ServiceUser } from '@/lib/services'; import AdminCertificationsSection from './admin-certifications-section'; import AdminUsersSection from './admin-users-section'; +import AdminVenuesSection from './admin-venues-section'; export default function AdminSection({ currentUser }: { currentUser: ServiceUser }) { const { isAdmin, loading } = useAdmin(); @@ -14,6 +15,7 @@ export default function AdminSection({ currentUser }: { currentUser: ServiceUser

Admin

+
); diff --git a/src/components/profile/admin-users-section.tsx b/src/components/profile/admin-users-section.tsx index 918779c9..d6397b4c 100644 --- a/src/components/profile/admin-users-section.tsx +++ b/src/components/profile/admin-users-section.tsx @@ -2,8 +2,8 @@ import { useEffect, useMemo, useState } from 'react'; import { Button, Card, CardBody, Input, Spinner, Switch } from '@heroui/react'; -import { RefreshCw } from 'lucide-react'; -import { dbService } from '@/lib/services'; +import { RefreshCw, Trash2 } from 'lucide-react'; +import { dbService, isPocketbaseBackend } from '@/lib/services'; import type { UserDoc } from '@/lib/userDoc'; import type { ServiceUser } from '@/lib/services'; @@ -23,11 +23,35 @@ function toRow(id: string, data: UserDoc & Record): UserRow { }; } +/** Deletes everything a user owns (venues, events, dispatch logs) plus their `users/{id}` doc. + * On PocketBase, deleting the `users` record also deletes the underlying auth account. + * On Firebase, the Auth account itself is untouched — it has no privileged server-side path + * here — so it must be removed separately via the Firebase Console if a full account removal + * is needed. */ +async function deleteUserAndData(userId: string): Promise { + const [venues, events, logs] = await Promise.all([ + dbService.queryCollection('venues', [{ field: 'userId', op: '==', value: userId }]), + dbService.queryCollection('events', [{ field: 'userId', op: '==', value: userId }]), + dbService.queryCollection('dispatchLogs', [{ field: 'userId', op: '==', value: userId }]), + ]); + + await Promise.all([ + ...venues.map((v) => dbService.deleteDocument('venues', v.id)), + ...events.map((e) => dbService.deleteDocument('events', e.id)), + ...logs.map((l) => dbService.deleteDocument('dispatchLogs', l.id)), + ]); + + await dbService.deleteDocument('users', userId); +} + export default function AdminUsersSection({ currentUser }: { currentUser: ServiceUser }) { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [updatingId, setUpdatingId] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); const load = async () => { setLoading(true); @@ -61,11 +85,26 @@ export default function AdminUsersSection({ currentUser }: { currentUser: Servic } }; + const handleDeleteConfirm = async () => { + if (!deleteTarget) return; + setDeleting(true); + setDeleteError(null); + try { + await deleteUserAndData(deleteTarget.id); + setRows((prev) => prev.filter((r) => r.id !== deleteTarget.id)); + setDeleteTarget(null); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : 'Failed to delete user.'); + } finally { + setDeleting(false); + } + }; + return (
-

Manage Admins

+

Manage Administrator Access

- {row.id === currentUser.uid && (you)} + {row.id === currentUser.uid ? ( + (you) + ) : ( + + )} toggleAdmin(row)} @@ -117,6 +172,47 @@ export default function AdminUsersSection({ currentUser }: { currentUser: Servic
)}
+ + {deleteTarget && ( +
+ + +

Delete Account

+

+ This permanently deletes {deleteTarget.email}'s + venues, events, dispatch logs, and profile + {isPocketbaseBackend ? ', including their sign-in account.' : '.'} This cannot be undone. +

+ {!isPocketbaseBackend && ( +

+ Their sign-in account itself is not removed by this action (Firebase Auth doesn't + allow that from the app). You can remove it separately in the Firebase Console if needed. +

+ )} + {deleteError &&

{deleteError}

} +
+ + +
+
+
+
+ )}
); } diff --git a/src/components/profile/admin-venues-section.tsx b/src/components/profile/admin-venues-section.tsx new file mode 100644 index 00000000..6f2abb8f --- /dev/null +++ b/src/components/profile/admin-venues-section.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Card, CardBody, Input, Spinner, Switch } from '@heroui/react'; +import { dbService } from '@/lib/services'; +import type { Venue } from '@/app/types'; +import type { ServiceUser } from '@/lib/services'; + +export default function AdminVenuesSection({ currentUser }: { currentUser: ServiceUser }) { + const [venues, setVenues] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [updatingId, setUpdatingId] = useState(null); + + useEffect(() => { + const load = async () => { + setLoading(true); + try { + const docs = await dbService.queryCollection('venues', [ + { field: 'userId', op: '==', value: currentUser.uid }, + ]); + setVenues(docs.map((d) => ({ ...(d.data as Venue), id: d.id }))); + } finally { + setLoading(false); + } + }; + load(); + }, [currentUser.uid]); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return venues; + return venues.filter((v) => (v.name || '').toLowerCase().includes(q)); + }, [venues, search]); + + const toggleOrgVenue = async (venue: Venue) => { + setUpdatingId(venue.id); + try { + await dbService.updateDocument('venues', venue.id, { isOrgVenue: !venue.isOrgVenue }); + setVenues((prev) => + prev.map((v) => (v.id === venue.id ? { ...v, isOrgVenue: !v.isOrgVenue } : v)), + ); + } finally { + setUpdatingId(null); + } + }; + + return ( + + +

Organization Venues

+

+ Venues you own that are toggled on appear in every user's venue list, instead of + being shared with people one at a time. +

+ + setSearch(e.target.value)} + placeholder="Search your venues" + classNames={{ + inputWrapper: 'rounded-2xl px-4 hover:bg-surface-deep', + input: 'text-surface-light outline-none focus:outline-none data-[focus=true]:outline-none', + }} + className="mb-4" + /> + + {loading ? ( + + ) : ( +
+ {filtered.map((venue) => ( +
+

{venue.name}

+ toggleOrgVenue(venue)} + isDisabled={updatingId === venue.id} + aria-label={`Organization venue toggle for ${venue.name}`} + /> +
+ ))} + {filtered.length === 0 && ( +

+ {venues.length === 0 ? "You haven't created any venues yet." : 'No venues found.'} +

+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/profile/security-section.tsx b/src/components/profile/security-section.tsx index 05ef7a89..55c7f13a 100644 --- a/src/components/profile/security-section.tsx +++ b/src/components/profile/security-section.tsx @@ -174,7 +174,7 @@ export default function SecuritySection({ user }: { user: ServiceUser }) { -
+

Password

{lastPasswordChange && ( diff --git a/tests/e2e/global-setup.pocketbase.ts b/tests/e2e/global-setup.pocketbase.ts index 98211b77..ca2fa991 100644 --- a/tests/e2e/global-setup.pocketbase.ts +++ b/tests/e2e/global-setup.pocketbase.ts @@ -108,6 +108,24 @@ async function ensureTestUser( } } +async function ensureField( + headers: AdminHeaders, + collectionName: string, + field: FieldDef, +): Promise { + const res = await pbFetch(`/api/collections/${collectionName}`, { headers }); + if (!res.ok) return; + const collection = (await res.json()) as { fields?: Array<{ name: string }> }; + const existing = collection.fields || []; + if (existing.some((f) => f.name === field.name)) return; + + await pbFetch(`/api/collections/${collectionName}`, { + method: 'PATCH', + headers, + body: JSON.stringify({ fields: [...existing, field] }), + }); +} + async function globalSetup(): Promise { const email = process.env.E2E_TEST_EMAIL; const password = process.env.E2E_TEST_PASSWORD; @@ -131,6 +149,7 @@ async function globalSetup(): Promise { { name: 'posts', type: 'json' }, { name: 'mapUrl', type: 'text' }, { name: 'sharedWith', type: 'json' }, + { name: 'isOrgVenue', type: 'bool' }, ]), ensureBaseCollection(headers, 'events', [ { name: 'name', type: 'text' }, @@ -159,6 +178,10 @@ async function globalSetup(): Promise { ]), ]); + // Covers .pb-data directories persisted from before this field existed — + // ensureBaseCollection only sets fields at creation time, not on existing collections. + await ensureField(headers, 'venues', { name: 'isOrgVenue', type: 'bool' }); + // Wipe all data collections to ensure a clean state for every test run await Promise.all([ wipeCollection(headers, 'venues'), From 3605460a630eadfab84588751df879bc59ea8b62 Mon Sep 17 00:00:00 2001 From: evanqua Date: Wed, 12 Aug 2026 12:46:42 -0700 Subject: [PATCH 03/16] feat(dispatch): add vocabulary registry, built-in presets, and translation context Lays the groundwork for customizable dispatch terminology: a canonical term registry keyed by the exact English strings already used internally (so the default preset is an identity mapping with zero data migration), CrowdCAD Default and CrowdCAD French built-in presets, and a DispatchVocabularyProvider context exposing t(key) for render-site translation. No wiring into the dispatch UI yet. Co-Authored-By: Claude Sonnet 5 --- src/lib/dispatchVocabulary/context.tsx | 44 ++++++++ src/lib/dispatchVocabulary/index.ts | 16 +++ src/lib/dispatchVocabulary/presets.ts | 130 +++++++++++++++++++++++ src/lib/dispatchVocabulary/terms.ts | 140 +++++++++++++++++++++++++ src/lib/dispatchVocabulary/types.ts | 12 +++ 5 files changed, 342 insertions(+) create mode 100644 src/lib/dispatchVocabulary/context.tsx create mode 100644 src/lib/dispatchVocabulary/index.ts create mode 100644 src/lib/dispatchVocabulary/presets.ts create mode 100644 src/lib/dispatchVocabulary/terms.ts create mode 100644 src/lib/dispatchVocabulary/types.ts diff --git a/src/lib/dispatchVocabulary/context.tsx b/src/lib/dispatchVocabulary/context.tsx new file mode 100644 index 00000000..45e6c18f --- /dev/null +++ b/src/lib/dispatchVocabulary/context.tsx @@ -0,0 +1,44 @@ +'use client'; +import { createContext, useCallback, useContext, useMemo } from 'react'; +import { BUILTIN_PRESETS, DEFAULT_PRESET_ID } from './presets'; + +/** + * Dispatch vocabulary is a pure display-mapping layer keyed off stable + * internal English strings (see terms.ts). Only render sites should call + * `t(key)` — every comparison, stored value, and business-logic branch in + * the dispatch page/components must keep using the raw English key + * (e.g. `status === 'Delivered'`), never the translated label. + */ +interface DispatchVocabularyContextValue { + t: (key: string) => string; +} + +const DispatchVocabularyContext = createContext(null); + +export function DispatchVocabularyProvider({ + terms, + children, +}: { + terms: Record; + children: React.ReactNode; +}) { + const t = useCallback((key: string) => terms[key] ?? key, [terms]); + const value = useMemo(() => ({ t }), [t]); + + return ( + + {children} + + ); +} + +/** Falls back to the CrowdCAD Default (identity) mapping outside a provider. */ +export function useDispatchTerms(): DispatchVocabularyContextValue { + const ctx = useContext(DispatchVocabularyContext); + if (ctx) return ctx; + return { t: (key: string) => BUILTIN_PRESETS[DEFAULT_PRESET_ID].terms[key] ?? key }; +} + +export function useDispatchTerm(key: string): string { + return useDispatchTerms().t(key); +} diff --git a/src/lib/dispatchVocabulary/index.ts b/src/lib/dispatchVocabulary/index.ts new file mode 100644 index 00000000..be3674a9 --- /dev/null +++ b/src/lib/dispatchVocabulary/index.ts @@ -0,0 +1,16 @@ +export { DISPATCH_TERMS, DISPATCH_TERM_KEYS, DISPATCH_TERM_CATEGORY_LABELS } from './terms'; +export type { DispatchTerm, DispatchTermCategory } from './terms'; +export { + BUILTIN_PRESETS, + BUILTIN_PRESET_IDS, + DEFAULT_PRESET_ID, + FRENCH_PRESET_ID, + isBuiltinPresetId, +} from './presets'; +export type { BuiltinPresetId, DispatchVocabularyPresetSummary } from './presets'; +export type { DispatchVocabularyPreset } from './types'; +export { + DispatchVocabularyProvider, + useDispatchTerms, + useDispatchTerm, +} from './context'; diff --git a/src/lib/dispatchVocabulary/presets.ts b/src/lib/dispatchVocabulary/presets.ts new file mode 100644 index 00000000..14b2f447 --- /dev/null +++ b/src/lib/dispatchVocabulary/presets.ts @@ -0,0 +1,130 @@ +import { DISPATCH_TERMS } from './terms'; + +export const DEFAULT_PRESET_ID = 'crowdcad-default'; +export const FRENCH_PRESET_ID = 'crowdcad-french'; + +export const BUILTIN_PRESET_IDS = [DEFAULT_PRESET_ID, FRENCH_PRESET_ID] as const; +export type BuiltinPresetId = (typeof BUILTIN_PRESET_IDS)[number]; + +export interface DispatchVocabularyPresetSummary { + id: string; + name: string; + terms: Record; + /** Built-in presets have no owner; custom (forked) presets do. */ + createdByName?: string; +} + +// Identity mapping — every key displays as itself. +const CROWDCAD_DEFAULT_TERMS: Record = Object.fromEntries( + DISPATCH_TERMS.map((t) => [t.key, t.defaultLabel]), +); + +// CrowdCAD French. Entries marked with the user's own supplied swaps are +// used verbatim; the rest are filled in with contextually-appropriate +// French event-medical/dispatch terminology. +const CROWDCAD_FRENCH_TERMS: Record = { + // Entities & roles + Team: 'Équipe', + Call: 'Appel', + Clinic: 'Poste de secours', + Supervisor: 'Superviseur', + Post: 'Poste', + Equipment: 'Équipement', + Roaming: 'Mobile', + Lead: 'Responsable', + Walkup: 'Présentation spontanée', + Unknown: 'Inconnu', + + // Call statuses + Pending: 'En attente', + Assigned: 'Assigné', + 'En Route': 'En route', + 'On Scene': 'Sur les lieux', + Transporting: 'En transport', + Delivered: 'Remis', + Refusal: 'Refus de soins', + NMM: 'Sans objet médical', + 'Unable to Locate': 'Introuvable', + Rolled: 'Évacué', + 'Rolled from Scene': 'Transport direct', + Resolved: 'Résolu', + + // Team / supervisor / equipment-run statuses + Available: 'Disponible', + Detached: 'Dégagé', + 'On Break': 'En pause', + 'In Clinic': 'Au poste de secours', + 'Delivered Eq': 'Équipement livré', + 'En Route Eq': 'Équipement en route', + Assisting: 'En assistance', + + // Equipment + 'In Use': 'En utilisation', + + // Clinic outcomes + Discharged: 'Sortie', + AMA: 'SCAM', + 'Rolled from Clinic': 'Transféré', + Transported: 'Transporté', + + // Actions & labels + 'Total Calls': 'Appels totaux', + 'Call #': "N° d'appel", + 'Chief Complaint': 'Plainte principale', + 'A/S': 'A/S', + Status: 'Statut', + Location: 'Position', + 'Add Call': 'Ajouter un appel', + 'Add Team': 'Ajouter une équipe', + 'Add Supervisor': 'Ajouter un superviseur', + 'Add Equipment': 'Ajouter un équipement', + 'Add Patient': 'Ajouter un patient', + Calls: 'Appels', + Teams: 'Équipes', + Supervisors: 'Superviseurs', + 'No calls': 'Aucun appel', + 'No supervisors available': 'Aucun superviseur disponible', + 'No equipment available': 'Aucun équipement disponible', + 'No equipment configured': 'Aucun équipement configuré', + 'Total Patients': 'Total des patients', + 'Show Log': 'Afficher le registre', + 'Hide Log': 'Masquer le registre', + 'Mark as Duplicate': 'Marquer comme doublon', + 'Mark as Priority': 'Marquer comme prioritaire', + 'Remove Priority': 'Retirer la priorité', + 'Delete Call': "Supprimer l'appel", + 'Staging Location': 'Emplacement de préparation', + 'Delivery Team': 'Équipe de livraison', + 'Mark Ready': 'Marquer comme prêt', + 'Equipment Details': "Détails de l'équipement", + 'Supervisor Call Sign': 'Indicatif du superviseur', + 'Supervisor Name (optional)': 'Nom du superviseur (facultatif)', + Certification: 'Certification', + 'Refresh Post': 'Actualiser le poste', + 'No location': 'Aucune position', + 'Clinic Status': 'Statut du poste de secours', + 'Team Members': "Membres de l'équipe", + 'Team Status': "Statut de l'équipe", + 'Team Name': "Nom de l'équipe", + 'Add New Team': 'Ajouter une nouvelle équipe', + 'Edit Team': "Modifier l'équipe", + 'Add New Supervisor': 'Ajouter un nouveau superviseur', + 'Edit Supervisor': 'Modifier le superviseur', +}; + +export const BUILTIN_PRESETS: Record = { + [DEFAULT_PRESET_ID]: { + id: DEFAULT_PRESET_ID, + name: 'CrowdCAD Default', + terms: CROWDCAD_DEFAULT_TERMS, + }, + [FRENCH_PRESET_ID]: { + id: FRENCH_PRESET_ID, + name: 'CrowdCAD French', + terms: CROWDCAD_FRENCH_TERMS, + }, +}; + +export function isBuiltinPresetId(id: string): id is BuiltinPresetId { + return (BUILTIN_PRESET_IDS as readonly string[]).includes(id); +} diff --git a/src/lib/dispatchVocabulary/terms.ts b/src/lib/dispatchVocabulary/terms.ts new file mode 100644 index 00000000..e1c3d723 --- /dev/null +++ b/src/lib/dispatchVocabulary/terms.ts @@ -0,0 +1,140 @@ +/** + * Canonical registry of dispatch vocabulary terms. + * + * Each `key` is the exact English string already used internally throughout + * the dispatch page/components — as stored data (`Call.status`, `Staff.status`, + * `Staff.location`, etc.), as a comparison target in business logic, or as + * literal JSX text. Using the existing string as the key means the identity + * preset (CrowdCAD Default) requires no data migration, and every render site + * can look up its label via `t(key)` without touching the underlying value. + * + * IMPORTANT: keys are internal identifiers, not just English words. Never + * rename a key — only its label may change per preset. See + * src/lib/dispatchVocabulary/README.md is intentionally omitted; the + * constraint is documented on DispatchVocabularyProvider instead. + */ + +export type DispatchTermCategory = + | 'entities' + | 'callStatuses' + | 'teamStatuses' + | 'equipment' + | 'clinicOutcomes' + | 'actions'; + +export interface DispatchTerm { + key: string; + category: DispatchTermCategory; + defaultLabel: string; +} + +function terms(category: DispatchTermCategory, keys: string[]): DispatchTerm[] { + return keys.map((key) => ({ key, category, defaultLabel: key })); +} + +export const DISPATCH_TERMS: DispatchTerm[] = [ + // Entities & roles — nouns for the people/things being dispatched. + ...terms('entities', [ + 'Team', + 'Call', + 'Clinic', + 'Supervisor', + 'Post', + 'Equipment', + 'Roaming', + 'Lead', + 'Walkup', + 'Unknown', + ]), + + // Call statuses — Call.status values, also used for team/supervisor + // composite call-driven statuses (En Route, On Scene, Transporting). + ...terms('callStatuses', [ + 'Pending', + 'Assigned', + 'En Route', + 'On Scene', + 'Transporting', + 'Delivered', + 'Refusal', + 'NMM', + 'Unable to Locate', + 'Rolled', + 'Rolled from Scene', + 'Resolved', + ]), + + // Team / supervisor / equipment-run statuses — Staff.status / Supervisor.status. + ...terms('teamStatuses', [ + 'Available', + 'Detached', + 'On Break', + 'In Clinic', + 'Delivered Eq', + 'En Route Eq', + 'Assisting', + ]), + + // Equipment. + ...terms('equipment', ['In Use']), + + // ClinicOutcome union (app/types.ts). + ...terms('clinicOutcomes', ['Discharged', 'AMA', 'Rolled from Clinic', 'Transported']), + + // UI copy: buttons, headers, field labels, empty states, menu items. + ...terms('actions', [ + 'Total Calls', + 'Call #', + 'Chief Complaint', + 'A/S', + 'Status', + 'Location', + 'Add Call', + 'Add Team', + 'Add Supervisor', + 'Add Equipment', + 'Add Patient', + 'Calls', + 'Teams', + 'Supervisors', + 'No calls', + 'No supervisors available', + 'No equipment available', + 'No equipment configured', + 'Total Patients', + 'Show Log', + 'Hide Log', + 'Mark as Duplicate', + 'Mark as Priority', + 'Remove Priority', + 'Delete Call', + 'Staging Location', + 'Delivery Team', + 'Mark Ready', + 'Equipment Details', + 'Supervisor Call Sign', + 'Supervisor Name (optional)', + 'Certification', + 'Refresh Post', + 'No location', + 'Clinic Status', + 'Team Members', + 'Team Status', + 'Team Name', + 'Add New Team', + 'Edit Team', + 'Add New Supervisor', + 'Edit Supervisor', + ]), +]; + +export const DISPATCH_TERM_KEYS: string[] = DISPATCH_TERMS.map((t) => t.key); + +export const DISPATCH_TERM_CATEGORY_LABELS: Record = { + entities: 'Entities & Roles', + callStatuses: 'Call Statuses', + teamStatuses: 'Team & Supervisor Statuses', + equipment: 'Equipment', + clinicOutcomes: 'Clinic Outcomes', + actions: 'Actions & Labels', +}; diff --git a/src/lib/dispatchVocabulary/types.ts b/src/lib/dispatchVocabulary/types.ts new file mode 100644 index 00000000..6faa07f7 --- /dev/null +++ b/src/lib/dispatchVocabulary/types.ts @@ -0,0 +1,12 @@ +/** A user-forked, org-shared dispatch vocabulary preset (stored in the `dispatchVocabularyPresets` collection). */ +export interface DispatchVocabularyPreset { + id: string; + name: string; + /** Full term map (a fork copies the whole map, not a diff). */ + terms: Record; + createdBy: string; + createdByName?: string; + /** Preset id (built-in or custom) this was forked from. */ + basedOn?: string; + createdAt: number; +} From 8663f9ae72eb69e973be2de49e255df2028487fe Mon Sep 17 00:00:00 2001 From: evanqua Date: Wed, 12 Aug 2026 12:48:08 -0700 Subject: [PATCH 04/16] feat(dispatch): persist dispatch vocabulary preset selection and shared forks Adds UserDoc.dispatchVocabularyPresetId, a dispatchVocabularyPresets collection service (list/create, following the same flat/unfiltered query pattern as org venues so custom presets are visible to every signed-in user), and useDispatchVocabulary to tie it together with a localStorage fallback for lite/unauthenticated mode. Co-Authored-By: Claude Sonnet 5 --- src/hooks/useDispatchVocabulary.ts | 145 +++++++++++++++++++ src/lib/dispatchVocabulary/localStore.ts | 39 +++++ src/lib/dispatchVocabulary/presetsService.ts | 33 +++++ src/lib/userDoc.ts | 2 + 4 files changed, 219 insertions(+) create mode 100644 src/hooks/useDispatchVocabulary.ts create mode 100644 src/lib/dispatchVocabulary/localStore.ts create mode 100644 src/lib/dispatchVocabulary/presetsService.ts diff --git a/src/hooks/useDispatchVocabulary.ts b/src/hooks/useDispatchVocabulary.ts new file mode 100644 index 00000000..54c806ec --- /dev/null +++ b/src/hooks/useDispatchVocabulary.ts @@ -0,0 +1,145 @@ +'use client'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAuth } from '@/hooks/useauth'; +import { dbService } from '@/lib/services'; +import type { UserDoc } from '@/lib/userDoc'; +import { + BUILTIN_PRESETS, + DEFAULT_PRESET_ID, + FRENCH_PRESET_ID, + isBuiltinPresetId, + type DispatchVocabularyPresetSummary, +} from '@/lib/dispatchVocabulary/presets'; +import type { DispatchVocabularyPreset } from '@/lib/dispatchVocabulary/types'; +import { createCustomPreset, listCustomPresets } from '@/lib/dispatchVocabulary/presetsService'; +import { + addLocalCustomPreset, + getLocalCustomPresets, + getLocalPresetId, + setLocalPresetId, +} from '@/lib/dispatchVocabulary/localStore'; + +export function useDispatchVocabulary() { + const { user } = useAuth(); + const [presetId, setPresetIdState] = useState(DEFAULT_PRESET_ID); + const [customPresets, setCustomPresets] = useState([]); + const [loading, setLoading] = useState(true); + + // Shared custom presets — visible to every signed-in user, same as org venues. + useEffect(() => { + if (!user) { + setCustomPresets(getLocalCustomPresets()); + return; + } + let cancelled = false; + listCustomPresets() + .then((presets) => { + if (!cancelled) setCustomPresets(presets); + }) + .catch(() => { + // Collection may not exist yet on this backend — fall back to built-ins only. + }); + return () => { + cancelled = true; + }; + }, [user]); + + // The signed-in user's chosen preset id (or the lite-mode localStorage fallback). + useEffect(() => { + let cancelled = false; + + async function load() { + setLoading(true); + try { + if (user) { + const snap = await dbService.getDocument('users', user.uid); + const id = snap.data?.dispatchVocabularyPresetId; + if (!cancelled) setPresetIdState(id || DEFAULT_PRESET_ID); + } else { + const stored = getLocalPresetId(); + if (!cancelled) setPresetIdState(stored || DEFAULT_PRESET_ID); + } + } finally { + if (!cancelled) setLoading(false); + } + } + + load(); + return () => { + cancelled = true; + }; + }, [user]); + + const setActivePresetId = useCallback( + async (id: string) => { + setPresetIdState(id); + if (user) { + await dbService.setDocument( + 'users', + user.uid, + { dispatchVocabularyPresetId: id }, + { merge: true }, + ); + } else { + setLocalPresetId(id); + } + }, + [user], + ); + + /** Forks a new named preset from a full term map and switches to it. Never mutates an existing preset. */ + const forkPreset = useCallback( + async (name: string, terms: Record, basedOn?: string) => { + if (user) { + const id = await createCustomPreset({ + name, + terms, + createdBy: user.uid, + createdByName: user.displayName || user.email || undefined, + basedOn, + }); + const preset: DispatchVocabularyPreset = { + id, + name, + terms, + createdBy: user.uid, + createdByName: user.displayName || user.email || undefined, + basedOn, + createdAt: Date.now(), + }; + setCustomPresets((prev) => [...prev, preset]); + await setActivePresetId(id); + return id; + } + + // Lite/unauthenticated mode: no org to share with, keep it local to this browser. + const preset: DispatchVocabularyPreset = { + id: `local-${Date.now()}`, + name, + terms, + createdBy: 'local', + basedOn, + createdAt: Date.now(), + }; + addLocalCustomPreset(preset); + setCustomPresets((prev) => [...prev, preset]); + await setActivePresetId(preset.id); + return preset.id; + }, + [user, setActivePresetId], + ); + + const availablePresets: DispatchVocabularyPresetSummary[] = useMemo( + () => [BUILTIN_PRESETS[DEFAULT_PRESET_ID], BUILTIN_PRESETS[FRENCH_PRESET_ID], ...customPresets], + [customPresets], + ); + + const activePreset: DispatchVocabularyPresetSummary = useMemo(() => { + if (isBuiltinPresetId(presetId)) return BUILTIN_PRESETS[presetId]; + return ( + customPresets.find((p) => p.id === presetId) ?? BUILTIN_PRESETS[DEFAULT_PRESET_ID] + ); + }, [presetId, customPresets]); + + return { presetId, activePreset, availablePresets, customPresets, setActivePresetId, forkPreset, loading }; +} diff --git a/src/lib/dispatchVocabulary/localStore.ts b/src/lib/dispatchVocabulary/localStore.ts new file mode 100644 index 00000000..38be9fa9 --- /dev/null +++ b/src/lib/dispatchVocabulary/localStore.ts @@ -0,0 +1,39 @@ +import type { DispatchVocabularyPreset } from './types'; + +const PRESET_ID_KEY = 'ccad-dispatch-vocabulary-preset-id'; +const LOCAL_PRESETS_KEY = 'ccad-dispatch-vocabulary-local-presets'; + +/** Fallback persistence for lite/unauthenticated mode, where there's no user doc to sync to. */ +export function getLocalPresetId(): string | null { + try { + return localStorage.getItem(PRESET_ID_KEY); + } catch { + return null; + } +} + +export function setLocalPresetId(id: string) { + try { + localStorage.setItem(PRESET_ID_KEY, id); + } catch { + // localStorage unavailable — in-memory state still updates + } +} + +export function getLocalCustomPresets(): DispatchVocabularyPreset[] { + try { + const raw = localStorage.getItem(LOCAL_PRESETS_KEY); + return raw ? (JSON.parse(raw) as DispatchVocabularyPreset[]) : []; + } catch { + return []; + } +} + +export function addLocalCustomPreset(preset: DispatchVocabularyPreset) { + try { + const existing = getLocalCustomPresets(); + localStorage.setItem(LOCAL_PRESETS_KEY, JSON.stringify([...existing, preset])); + } catch { + // localStorage unavailable — preset only lives in memory this session + } +} diff --git a/src/lib/dispatchVocabulary/presetsService.ts b/src/lib/dispatchVocabulary/presetsService.ts new file mode 100644 index 00000000..e80ce828 --- /dev/null +++ b/src/lib/dispatchVocabulary/presetsService.ts @@ -0,0 +1,33 @@ +import { dbService } from '@/lib/services'; +import type { DispatchVocabularyPreset } from './types'; + +const COLLECTION = 'dispatchVocabularyPresets'; + +/** All shared custom presets, visible to every signed-in user (same flat/unfiltered pattern as org venues). */ +export async function listCustomPresets(): Promise { + const docs = await dbService.getCollection(COLLECTION); + return docs.map((d) => ({ ...(d.data as DispatchVocabularyPreset), id: d.id })); +} + +export async function getCustomPreset(id: string): Promise { + const snap = await dbService.getDocument(COLLECTION, id); + if (!snap.exists || !snap.data) return null; + return { ...snap.data, id }; +} + +export async function createCustomPreset(input: { + name: string; + terms: Record; + createdBy: string; + createdByName?: string; + basedOn?: string; +}): Promise { + return dbService.addDocument>(COLLECTION, { + name: input.name, + terms: input.terms, + createdBy: input.createdBy, + createdByName: input.createdByName, + basedOn: input.basedOn, + createdAt: Date.now(), + }); +} diff --git a/src/lib/userDoc.ts b/src/lib/userDoc.ts index a579aee5..c86e7b49 100644 --- a/src/lib/userDoc.ts +++ b/src/lib/userDoc.ts @@ -5,4 +5,6 @@ export interface UserDoc { phoneNumber?: string | null; lastPasswordChange?: unknown; isAdmin?: boolean; + /** Built-in preset id ('crowdcad-default' | 'crowdcad-french') or a dispatchVocabularyPresets doc id. */ + dispatchVocabularyPresetId?: string; } From eae24a73850a6b76bc32068307407e5ae15a7dcc Mon Sep 17 00:00:00 2001 From: evanqua Date: Wed, 12 Aug 2026 13:00:04 -0700 Subject: [PATCH 05/16] feat(dispatch): add Language section to profile Preferences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New card in Preferences lets a user pick a dispatch vocabulary preset (built-in or a shared custom one) and browse/edit every term grouped by category. Editing always forks a newly-named, org-shared preset rather than mutating the one currently selected — verified end-to-end in the browser (preset switch, term editor, fork-and-save all round-trip correctly). Co-Authored-By: Claude Sonnet 5 --- src/components/profile/language-section.tsx | 196 ++++++++++++++++++ .../profile/preferences-section.tsx | 3 + 2 files changed, 199 insertions(+) create mode 100644 src/components/profile/language-section.tsx diff --git a/src/components/profile/language-section.tsx b/src/components/profile/language-section.tsx new file mode 100644 index 00000000..e917a744 --- /dev/null +++ b/src/components/profile/language-section.tsx @@ -0,0 +1,196 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Card, CardBody, Select, SelectItem, Input, Button } from '@heroui/react'; +import { useDispatchVocabulary } from '@/hooks/useDispatchVocabulary'; +import { + DISPATCH_TERMS, + DISPATCH_TERM_CATEGORY_LABELS, + type DispatchTermCategory, +} from '@/lib/dispatchVocabulary/terms'; + +const inputClassNames = { + label: 'text-surface-light/70 mb-1 text-xs', + inputWrapper: 'rounded-xl px-3 h-10 hover:bg-surface-deep', + input: 'text-surface-light text-sm outline-none focus:outline-none data-[focus=true]:outline-none', +} as const; + +const selectClassNames = { + label: 'text-surface-light mb-1', + trigger: + 'rounded-2xl px-4 border border-surface-liner bg-transparent hover:bg-surface-deep data-[focus=true]:outline-none', + value: 'text-surface-light', + popover: 'bg-surface-deepest border border-surface-liner rounded-2xl', + listbox: 'p-1 [&_[data-hover=true]]:bg-surface-deep [&_[data-selected=true]]:bg-surface-deep', +} as const; + +const CATEGORY_ORDER: DispatchTermCategory[] = [ + 'entities', + 'callStatuses', + 'teamStatuses', + 'equipment', + 'clinicOutcomes', + 'actions', +]; + +export default function LanguageSection() { + const { activePreset, availablePresets, setActivePresetId, forkPreset, loading } = + useDispatchVocabulary(); + + const [draftTerms, setDraftTerms] = useState>(activePreset.terms); + const [editing, setEditing] = useState(false); + const [savingName, setSavingName] = useState(null); + const [saving, setSaving] = useState(false); + + // Sync the draft whenever the selected preset changes (switching presets discards + // any unsaved edits — editing always forks a new preset rather than mutating one). + useEffect(() => { + setDraftTerms(activePreset.terms); + }, [activePreset]); + + const dirty = useMemo( + () => DISPATCH_TERMS.some((t) => draftTerms[t.key] !== activePreset.terms[t.key]), + [draftTerms, activePreset], + ); + + const termsByCategory = useMemo(() => { + const grouped = new Map(); + for (const term of DISPATCH_TERMS) { + const list = grouped.get(term.category) ?? []; + list.push(term); + grouped.set(term.category, list); + } + return grouped; + }, []); + + const handleSaveAsNewPreset = async () => { + const name = savingName?.trim(); + if (!name) return; + setSaving(true); + try { + await forkPreset(name, draftTerms, activePreset.id); + setSavingName(null); + } finally { + setSaving(false); + } + }; + + return ( + + +
+

Dispatch language

+

+ Choose the terminology used across the dispatch interface. Presets you create are + shared with everyone in your org. +

+
+ + + + + + {editing && ( +
+ {CATEGORY_ORDER.map((category) => { + const categoryTerms = termsByCategory.get(category); + if (!categoryTerms?.length) return null; + return ( +
+

+ {DISPATCH_TERM_CATEGORY_LABELS[category]} +

+
+ {categoryTerms.map((term) => ( + + setDraftTerms((prev) => ({ ...prev, [term.key]: value })) + } + aria-label={`Label for ${term.key}`} + /> + ))} +
+
+ ); + })} +
+ )} + + {dirty && ( +
+

+ Editing terms creates a new preset — it won't change "{activePreset.name} + " for anyone else. +

+
+ +
+ + +
+
+
+ )} +
+
+ ); +} diff --git a/src/components/profile/preferences-section.tsx b/src/components/profile/preferences-section.tsx index 03014f57..db56c38c 100644 --- a/src/components/profile/preferences-section.tsx +++ b/src/components/profile/preferences-section.tsx @@ -2,6 +2,7 @@ import { Card, CardBody, Switch } from '@heroui/react'; import { useReducedMotion } from '@/hooks/useReducedMotion'; +import LanguageSection from './language-section'; export default function PreferencesSection() { const { enabled, setEnabled } = useReducedMotion(); @@ -23,6 +24,8 @@ export default function PreferencesSection() {
+ +
); } From f9d286e60cd21902fad5b9e139c4100f0649e260 Mon Sep 17 00:00:00 2001 From: evanqua Date: Wed, 12 Aug 2026 13:06:07 -0700 Subject: [PATCH 06/16] feat(dispatch): wire vocabulary provider into the dispatch page shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the dispatch route in DispatchVocabularyProvider and switches its own render-site labels (tab titles, section headers, add/empty-state copy) to t() lookups, leaving every status comparison and stored value untouched. Verified in-browser with a live lite event: switching to CrowdCAD French correctly renders "Équipes", "Appels totaux", "Ajouter un appel", etc. Also adds a few chrome terms (No teams available, Show/Hide Resolved Calls) discovered while wiring. Co-Authored-By: Claude Sonnet 5 --- .../(main)/events/[eventId]/dispatch/page.tsx | 79 ++++++++++--------- src/lib/dispatchVocabulary/presets.ts | 3 + src/lib/dispatchVocabulary/terms.ts | 3 + 3 files changed, 48 insertions(+), 37 deletions(-) diff --git a/src/app/(main)/events/[eventId]/dispatch/page.tsx b/src/app/(main)/events/[eventId]/dispatch/page.tsx index 99feaf4c..673cc233 100644 --- a/src/app/(main)/events/[eventId]/dispatch/page.tsx +++ b/src/app/(main)/events/[eventId]/dispatch/page.tsx @@ -32,6 +32,8 @@ import EquipmentCard from '@/components/dispatch/equipmentcard'; import LoadingScreen from '@/components/ui/loading-screen'; import { normalizeLiteDraftToEvent, removeUndefinedDeep, toLiteDraftFromEvent } from '@/lib/liteEventAdapters'; import { getRowStatusClass } from '@/lib/statusColors'; +import { useDispatchVocabulary } from '@/hooks/useDispatchVocabulary'; +import { DispatchVocabularyProvider } from '@/lib/dispatchVocabulary/context'; const DEFAULT_CLINICS: Clinic[] = [{ id: 'clinic', name: 'Clinic' }]; @@ -73,6 +75,9 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { const { user: authUser, ready: authReady } = useAuth(); const user = isLiteMode ? null : authUser; const ready = isLiteMode ? true : authReady; + const { activePreset: dispatchVocabularyPreset } = useDispatchVocabulary(); + const vocabularyTerms = dispatchVocabularyPreset.terms; + const t = useCallback((key: string) => vocabularyTerms[key] ?? key, [vocabularyTerms]); const router = useRouter(); const [openCallId, setOpenCallId] = useState(null); const [openClinicCallId, setOpenClinicCallId] = useState(null); @@ -2805,10 +2810,10 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { @@ -2835,8 +2840,8 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { } }} > - Add Team - Add Supervisor + {t('Add Team')} + {t('Add Supervisor')}
@@ -2958,7 +2963,7 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { ); return ( - <> + {/* All your modals first - unchanged */} - Teams - Supervisors - Equipment + {t('Teams')} + {t('Supervisors')} + {t('Equipment')} @@ -3143,7 +3148,7 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { ))} {(!event?.staff || event.staff.length === 0) && (
- No teams available + {t('No teams available')}
)}
@@ -3222,7 +3227,7 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { ) : (
- No equipment configured + {t('No equipment configured')}
)}
@@ -3246,7 +3251,7 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { className={`tab-chrome relative h-10 px-4 text-[15px] sm:text-base font-semibold rounded-t-[20px] rounded-b-none transition-colors ${selectedRightTab === 'calls' ? "tab-active bg-surface-deep text-surface-light after:content-[''] after:absolute after:left-0 after:right-0 after:top-full after:h-3 after:bg-surface-deep" : 'bg-transparent border-0 text-surface-faint hover:text-surface-light'}`} aria-pressed={selectedRightTab === 'calls'} > - Calls ({activeCallsCount}) + {t('Calls')} ({activeCallsCount}) {clinics.map((clinic) => ( @@ -3265,18 +3270,18 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { {selectedRightTab === 'calls' && !isMobile && (
-

Total Calls: {event.calls?.length || 0}

- +

{t('Total Calls')}: {event.calls?.length || 0}

+
@@ -3315,17 +3320,17 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { {clinics.map((clinic) => selectedRightTab === clinic.id && !isMobile && (
-

Total Patients: {getClinicCalls(clinic.id).length}

- +

{t('Total Patients')}: {getClinicCalls(clinic.id).length}

+
@@ -3376,11 +3381,11 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { }} > {/* TEAMS TAB */} - +
-

Teams

+

{t('Teams')}

@@ -3424,7 +3429,7 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { {event?.supervisor && event.supervisor.length > 0 && (
-

Supervisors

+

{t('Supervisors')}

{event.supervisor @@ -3465,11 +3470,11 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { {/* EQUIPMENT TAB */} - +
-

Equipment

+

{t('Equipment')}

@@ -3496,7 +3501,7 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) {
) : (
- No equipment configured + {t('No equipment configured')}
)}
@@ -3504,19 +3509,19 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) {
- +
-

Calls

+

{t('Calls')}

- +
-
@@ -3612,7 +3617,7 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { onClick={() => setShowResolvedCalls(prev => !prev)} className="text-surface-faint text-base hover:text-surface-light" aria-label="Toggle resolved calls" - > {showResolvedCalls ? 'Hide Resolved Calls' : 'Show Resolved Calls'} + > {showResolvedCalls ? t('Hide Resolved Calls') : t('Show Resolved Calls')}
@@ -3844,6 +3849,6 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) {
)} - + ); } diff --git a/src/lib/dispatchVocabulary/presets.ts b/src/lib/dispatchVocabulary/presets.ts index 14b2f447..782db58b 100644 --- a/src/lib/dispatchVocabulary/presets.ts +++ b/src/lib/dispatchVocabulary/presets.ts @@ -83,9 +83,12 @@ const CROWDCAD_FRENCH_TERMS: Record = { Teams: 'Équipes', Supervisors: 'Superviseurs', 'No calls': 'Aucun appel', + 'No teams available': 'Aucune équipe disponible', 'No supervisors available': 'Aucun superviseur disponible', 'No equipment available': 'Aucun équipement disponible', 'No equipment configured': 'Aucun équipement configuré', + 'Show Resolved Calls': 'Afficher les appels résolus', + 'Hide Resolved Calls': 'Masquer les appels résolus', 'Total Patients': 'Total des patients', 'Show Log': 'Afficher le registre', 'Hide Log': 'Masquer le registre', diff --git a/src/lib/dispatchVocabulary/terms.ts b/src/lib/dispatchVocabulary/terms.ts index e1c3d723..b222b542 100644 --- a/src/lib/dispatchVocabulary/terms.ts +++ b/src/lib/dispatchVocabulary/terms.ts @@ -98,9 +98,12 @@ export const DISPATCH_TERMS: DispatchTerm[] = [ 'Teams', 'Supervisors', 'No calls', + 'No teams available', 'No supervisors available', 'No equipment available', 'No equipment configured', + 'Show Resolved Calls', + 'Hide Resolved Calls', 'Total Patients', 'Show Log', 'Hide Log', From c6a3d005ace8f21d204520fe4bb8af518722205e Mon Sep 17 00:00:00 2001 From: evanqua Date: Wed, 12 Aug 2026 13:11:15 -0700 Subject: [PATCH 07/16] feat(dispatch): wire vocabulary into call-tracking table, cards, and details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table headers (trackingtablebase.tsx), status chips/dropdowns, add-team/ supervisor/equipment submenus, log/duplicate/priority/delete actions, and the expanded call details panel now render through t() at each display site. Status dropdown keys stay on the canonical English value (key={status}) so handleTeamStatusChange and all resolved-status comparisons are untouched — only the rendered label translates. Adds a handful of terms discovered while wiring (No available teams/supervisors/equipment, Notes, Add notes, No log entries, Log for Call, the priority-call banner). Co-Authored-By: Claude Sonnet 5 --- src/components/dispatch/calltracking.tsx | 34 +++++++++-------- src/components/dispatch/calltrackingcard.tsx | 38 ++++++++++--------- .../dispatch/calltrackingdetails.tsx | 12 +++--- src/components/dispatch/trackingtablebase.tsx | 14 ++++--- src/lib/dispatchVocabulary/presets.ts | 8 ++++ src/lib/dispatchVocabulary/terms.ts | 8 ++++ 6 files changed, 69 insertions(+), 45 deletions(-) diff --git a/src/components/dispatch/calltracking.tsx b/src/components/dispatch/calltracking.tsx index 80680b15..8ad74088 100644 --- a/src/components/dispatch/calltracking.tsx +++ b/src/components/dispatch/calltracking.tsx @@ -17,6 +17,7 @@ import CallTrackingDetails from '@/components/dispatch/calltrackingdetails'; import DispatchMotionCell from './motioncell'; import TrackingTableBase from './trackingtablebase'; import { getStatusColor, TEAM_CARD_ROW_HOVER_CLASS } from '@/lib/statusColors'; +import { useDispatchTerms } from '@/lib/dispatchVocabulary/context'; import { Dropdownmenu, @@ -114,6 +115,7 @@ export const CallTrackingTable: React.FC = ({ // focus/blur-tracking race — see adobe/react-spectrum#4533). Real users // never dismiss a menu that fast, so ignore closes inside this window // unless they came from an actual selection (onAction). + const { t } = useDispatchTerms(); const TEAM_STATUS_MENU_CLOSE_GUARD_MS = 150; const teamStatusMenuOpenedAtRef = React.useRef(0); const teamStatusMenuSelectedRef = React.useRef(false); @@ -381,7 +383,7 @@ export const CallTrackingTable: React.FC = ({ color="default" className="text-surface-light h-9 shrink-0 border border-surface-liner bg-surface-liner/30" > - Pending + {t('Pending')} )} {/* Active assigned teams - Larger chips with centered dropdown */} @@ -430,7 +432,7 @@ export const CallTrackingTable: React.FC = ({ variant="light" className="min-w-0 h-6 px-2 text-xs shrink-0" > - {currentTeamStatus} + {t(currentTeamStatus)} = ({ }} > {statusOptions.map((status: string) => ( - {status} + {t(status)} ))} @@ -463,7 +465,7 @@ export const CallTrackingTable: React.FC = ({ {detachedTeam.team} - {detachedTeam.reason === 'Refusal' ? 'Refusal' : detachedTeam.reason} + {t(detachedTeam.reason)} ))} @@ -486,7 +488,7 @@ export const CallTrackingTable: React.FC = ({ {/* Add Team Submenu */} - Add Team + {t('Add Team')} {(() => { @@ -537,7 +539,7 @@ export const CallTrackingTable: React.FC = ({ {/* Add Supervisor Submenu */} - Add Supervisor + {t('Add Supervisor')} {event.supervisor @@ -612,7 +614,7 @@ export const CallTrackingTable: React.FC = ({ return notAssignedToThisCall && isAvailable; }).length === 0) && ( - No supervisors available + {t('No supervisors available')} )} @@ -621,7 +623,7 @@ export const CallTrackingTable: React.FC = ({ {/* Add Equipment Submenu */} - Add Equipment + {t('Add Equipment')} {(() => { @@ -1277,7 +1279,7 @@ export const CallTrackingTable: React.FC = ({ })()} {(!event.eventEquipment || (event.eventEquipment.filter((eq: Equipment) => eq.status === 'Available' || eq.status === 'In Clinic' || !eq.assignedTeam).length === 0)) && ( - No equipment available + {t('No equipment available')} )} @@ -1325,27 +1327,27 @@ export const CallTrackingTable: React.FC = ({ setOpenCallId(openCallId === call.id ? null : call.id); }} > - {openCallId === call.id ? 'Hide Log' : 'Show Log'} + {openCallId === call.id ? t('Hide Log') : t('Show Log')} - { setOpenMenuToken(null); handleMarkDuplicate(call.id); }} > - Mark as Duplicate + {t('Mark as Duplicate')} - { setOpenMenuToken(null); handleTogglePriorityFromMenu(call.id); }} > - {call.priority ? 'Remove Priority' : 'Mark as Priority'} + {call.priority ? t('Remove Priority') : t('Mark as Priority')} - = ({ } }} > - Delete Call + {t('Delete Call')} diff --git a/src/components/dispatch/calltrackingcard.tsx b/src/components/dispatch/calltrackingcard.tsx index e9bca51b..d50c4629 100644 --- a/src/components/dispatch/calltrackingcard.tsx +++ b/src/components/dispatch/calltrackingcard.tsx @@ -18,6 +18,7 @@ import { } from "@/components/ui/dropdown-menu" import type { Event, Call } from '@/app/types'; import TrackingTextEntry from '@/components/dispatch/trackingtextentry'; +import { useDispatchTerms } from '@/lib/dispatchVocabulary/context'; type CallTrackingCardProps = { call: Call; @@ -90,6 +91,7 @@ export default function CallTrackingCard({ getCallRowClass, updateEvent, }: CallTrackingCardProps) { + const { t } = useDispatchTerms(); const [expanded, setExpanded] = useState(false); const [locationInput, setLocationInput] = useState(call.location || ''); const [ageSexInput, setAgeSexInput] = useState(formatAgeSex(call.age, call.gender) || ''); @@ -207,21 +209,21 @@ export default function CallTrackingCard({ key="showLog" onPress={() => setExpanded(v => !v)} > - {expanded ? 'Hide Log' : 'Show Log'} + {expanded ? t('Hide Log') : t('Show Log')} - handleMarkDuplicate(call.id)} > - Mark as Duplicate + {t('Mark as Duplicate')} - handleTogglePriority(call.id)} > - {call.priority ? 'Remove Priority' : 'Mark as Priority'} + {call.priority ? t('Remove Priority') : t('Mark as Priority')} - - Delete Call + {t('Delete Call')} @@ -347,7 +349,7 @@ export default function CallTrackingCard({ }} className="text-xs text-surface-faint hover:text-surface-light transition-colors" > - {currentStatus} ▼ + {t(currentStatus)} ▼ handleTeamStatusChange(call.id, team, key as string)} > {statusOptions.map(status => ( - {status} + {t(status)} ))} @@ -377,7 +379,7 @@ export default function CallTrackingCard({ {detachedTeam.team} - {detachedTeam.reason === 'Refusal' ? 'Refusal' : detachedTeam.reason} + {t(detachedTeam.reason)} ))} @@ -399,7 +401,7 @@ export default function CallTrackingCard({ {/* Add Team Submenu */} - Add Team + {t('Add Team')} {availableStaff.length > 0 ? ( @@ -413,13 +415,13 @@ export default function CallTrackingCard({ isBreakOrClinic ? 'bg-status-card-blue text-surface-light' : 'text-surface-light' }`} > - {s.team} {isBreakOrClinic && `(${s.status})`} + {s.team} {isBreakOrClinic && `(${t(s.status)})`} ); }) ) : ( - No available teams + {t('No available teams')} )} @@ -428,7 +430,7 @@ export default function CallTrackingCard({ {/* Add Supervisor Submenu */} - Add Supervisor + {t('Add Supervisor')} {availableSupervisors.length > 0 ? ( @@ -442,13 +444,13 @@ export default function CallTrackingCard({ isBreakOrClinic ? 'bg-status-card-blue text-surface-light' : 'text-surface-light' }`} > - {s.team} {isBreakOrClinic && `(${s.status})`} + {s.team} {isBreakOrClinic && `(${t(s.status)})`} ); }) ) : ( - No available supervisors + {t('No available supervisors')} )} @@ -457,7 +459,7 @@ export default function CallTrackingCard({ {/* Add Equipment Submenu - with team selection */} - Add Equipment + {t('Add Equipment')} {availableEquipment.length > 0 ? ( @@ -554,7 +556,7 @@ export default function CallTrackingCard({ )) ) : ( - No available equipment + {t('No available equipment')} )} diff --git a/src/components/dispatch/calltrackingdetails.tsx b/src/components/dispatch/calltrackingdetails.tsx index f2bd208c..ccd25758 100644 --- a/src/components/dispatch/calltrackingdetails.tsx +++ b/src/components/dispatch/calltrackingdetails.tsx @@ -3,6 +3,7 @@ import React from 'react'; import DispatchMotionCell from './motioncell'; import TrackingTextEntry from '@/components/dispatch/trackingtextentry'; +import { useDispatchTerms } from '@/lib/dispatchVocabulary/context'; type Props = { callDisplayNumber: number | undefined; @@ -37,6 +38,7 @@ export default function CallTrackingDetails({ priority, rowClassName, }: Props) { + const { t } = useDispatchTerms(); return ( {priority && (
- ⚠️ PRIORITY CALL: Life threat to patient/provider + ⚠️ {t('PRIORITY CALL: Life threat to patient/provider')}
)} @@ -60,7 +62,7 @@ export default function CallTrackingDetails({ className="mt-0 mb-1.5 text-sm text-surface-light" onClick={(e) => e.stopPropagation()} > -
Notes
+
{t('Notes')}
e.stopPropagation()}> - Log for Call #{callDisplayNumber}: + {t('Log for Call')} #{callDisplayNumber}:
diff --git a/src/components/dispatch/trackingtablebase.tsx b/src/components/dispatch/trackingtablebase.tsx index 98b891a5..057d9794 100644 --- a/src/components/dispatch/trackingtablebase.tsx +++ b/src/components/dispatch/trackingtablebase.tsx @@ -1,6 +1,7 @@ 'use client'; import React from 'react'; +import { useDispatchTerms } from '@/lib/dispatchVocabulary/context'; interface TrackingTableBaseProps { TableColGroup: React.ComponentType; @@ -19,6 +20,7 @@ export default function TrackingTableBase({ footer, className, }: TrackingTableBaseProps) { + const { t } = useDispatchTerms(); return (
@@ -26,12 +28,12 @@ export default function TrackingTableBase({ - Call # - Chief Complaint - A/S - Location - {showStatusColumn && Status} - Team + {t('Call #')} + {t('Chief Complaint')} + {t('A/S')} + {t('Location')} + {showStatusColumn && {t('Status')}} + {t('Team')} diff --git a/src/lib/dispatchVocabulary/presets.ts b/src/lib/dispatchVocabulary/presets.ts index 782db58b..9bc94f75 100644 --- a/src/lib/dispatchVocabulary/presets.ts +++ b/src/lib/dispatchVocabulary/presets.ts @@ -84,8 +84,11 @@ const CROWDCAD_FRENCH_TERMS: Record = { Supervisors: 'Superviseurs', 'No calls': 'Aucun appel', 'No teams available': 'Aucune équipe disponible', + 'No available teams': 'Aucune équipe disponible', 'No supervisors available': 'Aucun superviseur disponible', + 'No available supervisors': 'Aucun superviseur disponible', 'No equipment available': 'Aucun équipement disponible', + 'No available equipment': 'Aucun équipement disponible', 'No equipment configured': 'Aucun équipement configuré', 'Show Resolved Calls': 'Afficher les appels résolus', 'Hide Resolved Calls': 'Masquer les appels résolus', @@ -113,6 +116,11 @@ const CROWDCAD_FRENCH_TERMS: Record = { 'Edit Team': "Modifier l'équipe", 'Add New Supervisor': 'Ajouter un nouveau superviseur', 'Edit Supervisor': 'Modifier le superviseur', + Notes: 'Notes', + 'Add notes': 'Ajouter des notes', + 'No log entries': "Aucune entrée dans le registre", + 'Log for Call': "Registre de l'appel", + 'PRIORITY CALL: Life threat to patient/provider': 'APPEL PRIORITAIRE : menace vitale pour le patient ou l’intervenant', }; export const BUILTIN_PRESETS: Record = { diff --git a/src/lib/dispatchVocabulary/terms.ts b/src/lib/dispatchVocabulary/terms.ts index b222b542..f4023a06 100644 --- a/src/lib/dispatchVocabulary/terms.ts +++ b/src/lib/dispatchVocabulary/terms.ts @@ -99,8 +99,11 @@ export const DISPATCH_TERMS: DispatchTerm[] = [ 'Supervisors', 'No calls', 'No teams available', + 'No available teams', 'No supervisors available', + 'No available supervisors', 'No equipment available', + 'No available equipment', 'No equipment configured', 'Show Resolved Calls', 'Hide Resolved Calls', @@ -128,6 +131,11 @@ export const DISPATCH_TERMS: DispatchTerm[] = [ 'Edit Team', 'Add New Supervisor', 'Edit Supervisor', + 'Notes', + 'Add notes', + 'No log entries', + 'Log for Call', + 'PRIORITY CALL: Life threat to patient/provider', ]), ]; From 7ac363e9f551b229418ce91274dc7b6f1ceee2c5 Mon Sep 17 00:00:00 2001 From: evanqua Date: Wed, 12 Aug 2026 13:17:36 -0700 Subject: [PATCH 08/16] feat(dispatch): wire vocabulary into team cards Status selects, location autocomplete, Refresh Post/Edit/Delete menu items, member list header, activity log placeholder, and the (Lead) display suffix in both teamcard.tsx and its condensed variant now render through t(). The (Lead) storage marker used by member-string parsing is left untouched; only the formatted display label is translated. Co-Authored-By: Claude Sonnet 5 --- .../dispatch/teamcard-condensed.tsx | 26 +++++++++-------- src/components/dispatch/teamcard.tsx | 28 ++++++++++--------- src/lib/dispatchVocabulary/presets.ts | 4 +++ src/lib/dispatchVocabulary/terms.ts | 4 +++ 4 files changed, 37 insertions(+), 25 deletions(-) diff --git a/src/components/dispatch/teamcard-condensed.tsx b/src/components/dispatch/teamcard-condensed.tsx index b041921c..8e7b30af 100644 --- a/src/components/dispatch/teamcard-condensed.tsx +++ b/src/components/dispatch/teamcard-condensed.tsx @@ -11,6 +11,7 @@ import type {Event, Staff} from '@/app/types'; import TrackingTextEntry from '@/components/dispatch/trackingtextentry'; import { deriveTeamVisualStatus, getStatusColor } from '@/lib/statusColors'; import DispatchMotionCell from './motioncell'; +import { useDispatchTerms } from '@/lib/dispatchVocabulary/context'; type TeamCardCondensedProps = { staff: Staff; @@ -43,6 +44,7 @@ export default function TeamCardCondensed({ onStatusChange, onLocationChange, onEdit, onDelete, onRefreshPost, updateEvent }: TeamCardCondensedProps) { + const { t } = useDispatchTerms(); const [expanded, setExpanded] = useState(false); // Persistent local state for log text — never goes null to prevent flicker const [logText, setLogText] = useState(() => { @@ -131,10 +133,10 @@ export default function TeamCardCondensed({ {staff.team} - {staff.status} + {t(staff.status)} - {staff.location || 'No location'} + {staff.location ? t(staff.location) : t('No location')}
@@ -166,9 +168,9 @@ export default function TeamCardCondensed({ if (key === 'delete') onDelete?.(staff.team); }} > - Refresh Post - Edit - Delete + {t('Refresh Post')} + {t('Edit')} + {t('Delete')}
@@ -211,7 +213,7 @@ export default function TeamCardCondensed({ }} > {statusOptions.map((s) => ( - {s} + {t(s)} ))}
@@ -263,7 +265,7 @@ export default function TeamCardCondensed({ }} > {postOptions.map(p => ( - {p} + {t(p)} ))}
@@ -273,15 +275,15 @@ export default function TeamCardCondensed({
{/* Members on left */}
-
Team Members
+
{t('Team Members')}
{allMembers.map((member, idx) => (
- {member.name} {member.cert ? `[${member.cert}]` : ''} {member.isLead ? '(Lead)' : ''} + {member.name} {member.cert ? `[${member.cert}]` : ''} {member.isLead ? `(${t('Lead')})` : ''}
))} {allMembers.length === 0 && ( -
No members
+
{t('No members')}
)}
@@ -296,7 +298,7 @@ export default function TeamCardCondensed({ {/* Activity log */}
e.stopPropagation()}> -
Activity Log
+
{t('Activity Log')}
string) { const isLead = member.includes('(Lead)'); const withoutLead = member.replace(/\s*\(Lead\)\s*/g, '').trim(); const certMatches = [...withoutLead.matchAll(/\[(.+?)\]/g)].map(match => match[1]).filter(Boolean); const name = withoutLead.replace(/\s*\[.+?\]/g, '').trim(); const certText = certMatches.map(cert => `[${cert}]`).join(' '); - const leadText = isLead ? ' [Lead]' : ''; + const leadText = isLead ? ` [${t('Lead')}]` : ''; return `${name}${certText ? ` ${certText}` : ''}${leadText}`.trim(); } @@ -53,6 +54,7 @@ export default function TeamCard({ onStatusChange, onLocationChange, onEdit, onDelete, onRefreshPost, updateEvent }: TeamCardProps) { + const { t } = useDispatchTerms(); const [expanded, setExpanded] = useState(false); // Persistent local state for log text — never goes null to prevent flicker const [logText, setLogText] = useState(() => { @@ -85,8 +87,8 @@ export default function TeamCard({ const members = Array.isArray(staff.members) ? staff.members : []; return members .filter((member): member is string => typeof member === 'string' && member.trim().length > 0) - .map(formatMemberLine); - }, [staff.members]); + .map(member => formatMemberLine(member, t)); + }, [staff.members, t]); const timer = useMMSS(sinceMs); // Status options @@ -164,9 +166,9 @@ export default function TeamCard({ if (key === 'delete') onDelete?.(staff.team); }} > - Refresh Post - Edit - Delete + {t('Refresh Post')} + {t('Edit')} + {t('Delete')}
@@ -206,7 +208,7 @@ export default function TeamCard({ }} > {statusOptions.map((s) => ( - {s} + {t(s)} ))}
@@ -262,7 +264,7 @@ export default function TeamCard({ }} > {postOptions.map(p => ( - {p} + {t(p)} ))}
@@ -274,7 +276,7 @@ export default function TeamCard({ onClick={e => e.stopPropagation()} aria-hidden={!expanded} > -
Team
+
{t('Team')}
{memberLines.map((line, index) => (
@@ -282,11 +284,11 @@ export default function TeamCard({
))} {memberLines.length === 0 && ( -
No members
+
{t('No members')}
)}
-
Activity Log
+
{t('Activity Log')}
diff --git a/src/lib/dispatchVocabulary/presets.ts b/src/lib/dispatchVocabulary/presets.ts index 9bc94f75..0b9c3c7c 100644 --- a/src/lib/dispatchVocabulary/presets.ts +++ b/src/lib/dispatchVocabulary/presets.ts @@ -121,6 +121,10 @@ const CROWDCAD_FRENCH_TERMS: Record = { 'No log entries': "Aucune entrée dans le registre", 'Log for Call': "Registre de l'appel", 'PRIORITY CALL: Life threat to patient/provider': 'APPEL PRIORITAIRE : menace vitale pour le patient ou l’intervenant', + Edit: 'Modifier', + Delete: 'Supprimer', + 'No members': 'Aucun membre', + 'Activity Log': 'Registre d’activité', }; export const BUILTIN_PRESETS: Record = { diff --git a/src/lib/dispatchVocabulary/terms.ts b/src/lib/dispatchVocabulary/terms.ts index f4023a06..42a5ae36 100644 --- a/src/lib/dispatchVocabulary/terms.ts +++ b/src/lib/dispatchVocabulary/terms.ts @@ -136,6 +136,10 @@ export const DISPATCH_TERMS: DispatchTerm[] = [ 'No log entries', 'Log for Call', 'PRIORITY CALL: Life threat to patient/provider', + 'Edit', + 'Delete', + 'No members', + 'Activity Log', ]), ]; From 41c4d53a8ddbcc915edcab7fa03029aa3bac5c59 Mon Sep 17 00:00:00 2001 From: evanqua Date: Wed, 12 Aug 2026 13:21:33 -0700 Subject: [PATCH 09/16] feat(dispatch): wire vocabulary into clinic tracking table and cards Clinic status dropdown (In Clinic/Transported/AMA/Discharged), Walkup fallback, primary team, notes/log panels, and the No clinic calls empty state now render through t(). Also catches No clinic calls in page.tsx, missed during the earlier page-shell pass. Co-Authored-By: Claude Sonnet 5 --- .../(main)/events/[eventId]/dispatch/page.tsx | 2 +- src/components/dispatch/clinictracking.tsx | 32 +++++++------- .../dispatch/clinictrackingcard.tsx | 42 ++++++++++--------- src/lib/dispatchVocabulary/presets.ts | 3 ++ src/lib/dispatchVocabulary/terms.ts | 3 ++ 5 files changed, 46 insertions(+), 36 deletions(-) diff --git a/src/app/(main)/events/[eventId]/dispatch/page.tsx b/src/app/(main)/events/[eventId]/dispatch/page.tsx index 673cc233..2b0d97a3 100644 --- a/src/app/(main)/events/[eventId]/dispatch/page.tsx +++ b/src/app/(main)/events/[eventId]/dispatch/page.tsx @@ -3716,7 +3716,7 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { ))} {getClinicCalls(clinic.id).length === 0 && (
- No clinic calls + {t('No clinic calls')}
)}
diff --git a/src/components/dispatch/clinictracking.tsx b/src/components/dispatch/clinictracking.tsx index 4565055c..ea79c364 100644 --- a/src/components/dispatch/clinictracking.tsx +++ b/src/components/dispatch/clinictracking.tsx @@ -15,6 +15,7 @@ import DispatchMotionCell from './motioncell'; import TrackingTableBase from './trackingtablebase'; import { TEAM_CARD_ROW_HOVER_CLASS } from '@/lib/statusColors'; import TrackingTextEntry from '@/components/dispatch/trackingtextentry'; +import { useDispatchTerms } from '@/lib/dispatchVocabulary/context'; type EditableCallField = keyof Call | 'ageSex'; @@ -80,6 +81,7 @@ export default function ClinicTrackingTable({ getCallRowClass, formatAgeSex, }: ClinicTrackingTableProps) { + const { t } = useDispatchTerms(); // Persistent local state for notes/log text per call — never goes null to prevent flicker const [notesTexts, setNotesTexts] = React.useState>({}); const notesFocusedRef = React.useRef(null); @@ -365,7 +367,7 @@ export default function ClinicTrackingTable({ variant="flat" className="min-w-0 h-7 px-2 text-xs justify-start bg-surface-liner hover:bg-surface-muted" > - {call.outcome || 'In Clinic'} + {t(call.outcome || 'In Clinic')} - In Clinic - Transported - AMA - Discharged + {t('In Clinic')} + {t('Transported')} + {t('AMA')} + {t('Discharged')} @@ -408,7 +410,7 @@ export default function ClinicTrackingTable({ {(call.assignedTeam && call.assignedTeam.length > 0) ? (Array.isArray(call.assignedTeam) ? call.assignedTeam.join(', ') : call.assignedTeam) - : (call.detachedTeams?.map(d => d.team).join(', ') || 'Walkup')} + : (call.detachedTeams?.map(d => d.team).join(', ') || t('Walkup'))} {/* Options Ellipsis */} @@ -446,7 +448,7 @@ export default function ClinicTrackingTable({ setOpenClinicCallId(openClinicCallId === call.id ? null : call.id); }} > - {openClinicCallId === call.id ? 'Hide Log' : 'Show Log'} + {openClinicCallId === call.id ? t('Hide Log') : t('Show Log')} - Delete Call + {t('Delete Call')} @@ -488,16 +490,16 @@ export default function ClinicTrackingTable({ {call.priority && (
- ⚠️ PRIORITY CALL: Life threat to patient/provider + ⚠️ {t('PRIORITY CALL: Life threat to patient/provider')}
)} - + {/* Notes - Using HeroUI Textarea - NO LOG ENTRY */}
e.stopPropagation()} > -
Notes
+
{t('Notes')}
- + {/* Log - Using HeroUI ScrollShadow */}
e.stopPropagation()}> - Log for Call #{callDisplayNumberMap.get(call.id)}: + {t('Log for Call')} #{callDisplayNumberMap.get(call.id)}: { @@ -577,7 +579,7 @@ export default function ClinicTrackingTable({ minRows={4} maxRows={5} variant="flat" - placeholder="No log entries" + placeholder={t('No log entries')} className="min-w-0" />
diff --git a/src/components/dispatch/clinictrackingcard.tsx b/src/components/dispatch/clinictrackingcard.tsx index 4042a59a..99942be1 100644 --- a/src/components/dispatch/clinictrackingcard.tsx +++ b/src/components/dispatch/clinictrackingcard.tsx @@ -9,6 +9,7 @@ import { import { MoreVertical } from 'lucide-react'; import type { Event, Call } from '@/app/types'; import TrackingTextEntry from '@/components/dispatch/trackingtextentry'; +import { useDispatchTerms } from '@/lib/dispatchVocabulary/context'; type ClinicTrackingCardProps = { call: Call; @@ -62,6 +63,7 @@ export default function ClinicTrackingCard({ getCallRowClass, updateEvent, }: ClinicTrackingCardProps) { + const { t } = useDispatchTerms(); const [expanded, setExpanded] = useState(false); const [locationInput, setLocationInput] = useState(call.location || ''); const [ageSexInput, setAgeSexInput] = useState(formatAgeSex(call.age, call.gender) || ''); @@ -136,7 +138,7 @@ export default function ClinicTrackingCard({ className="relative flex items-center justify-between px-4 py-3 pb-0 cursor-pointer select-none" >
- Call {callDisplayNumber} + {t('Call')} {callDisplayNumber}
{/* Right section: Timer and Menu aligned horizontally */} @@ -163,9 +165,9 @@ export default function ClinicTrackingCard({ key="showLog" onPress={() => setExpanded(v => !v)} > - {expanded ? 'Hide Log' : 'Show Log'} + {expanded ? t('Hide Log') : t('Show Log')} - - Delete Call + {t('Delete Call')} @@ -188,7 +190,7 @@ export default function ClinicTrackingCard({ {/* Row 1: Location */}
setLocationInput(e.target.value)} @@ -214,7 +216,7 @@ export default function ClinicTrackingCard({ {/* Row 2: Age/Sex (1/4) + Chief Complaint (3/4) */}
setAgeSexInput(e.target.value)} @@ -236,7 +238,7 @@ export default function ClinicTrackingCard({ className="w-1/4" /> setChiefComplaintInput(e.target.value)} @@ -271,8 +273,8 @@ export default function ClinicTrackingCard({ className="w-full h-full justify-start bg-surface-deep border border-surface-liner hover:bg-surface-muted text-surface-light px-2" >
-
Status
-
{call.outcome || 'In Clinic'}
+
{t('Status')}
+
{t(call.outcome || 'In Clinic')}
@@ -280,10 +282,10 @@ export default function ClinicTrackingCard({ aria-label="Clinic Status" onAction={(key) => onOutcomeChange(call.id, key as string)} > - In Clinic - Transported - AMA - Discharged + {t('In Clinic')} + {t('Transported')} + {t('AMA')} + {t('Discharged')}
@@ -291,8 +293,8 @@ export default function ClinicTrackingCard({ {/* Primary Team (read-only) */}
-
Primary Team
-
{primaryTeam}
+
{t('Primary Team')}
+
{t(primaryTeam)}
@@ -302,13 +304,13 @@ export default function ClinicTrackingCard({
e.stopPropagation()}> {call.priority && (
- ⚠️ PRIORITY CALL: Life threat to patient/provider + ⚠️ {t('PRIORITY CALL: Life threat to patient/provider')}
)} {/* Notes - NO LOG ENTRY */}
-
Notes
+
{t('Notes')}
{/* Log - Editable Textarea */}
-
Log for Call #{callDisplayNumber}:
+
{t('Log for Call')} #{callDisplayNumber}:
diff --git a/src/lib/dispatchVocabulary/presets.ts b/src/lib/dispatchVocabulary/presets.ts index 0b9c3c7c..58ded754 100644 --- a/src/lib/dispatchVocabulary/presets.ts +++ b/src/lib/dispatchVocabulary/presets.ts @@ -72,7 +72,9 @@ const CROWDCAD_FRENCH_TERMS: Record = { 'Call #': "N° d'appel", 'Chief Complaint': 'Plainte principale', 'A/S': 'A/S', + 'Age/Sex': 'A/S', Status: 'Statut', + 'Primary Team': 'Équipe principale', Location: 'Position', 'Add Call': 'Ajouter un appel', 'Add Team': 'Ajouter une équipe', @@ -83,6 +85,7 @@ const CROWDCAD_FRENCH_TERMS: Record = { Teams: 'Équipes', Supervisors: 'Superviseurs', 'No calls': 'Aucun appel', + 'No clinic calls': 'Aucun appel au poste de secours', 'No teams available': 'Aucune équipe disponible', 'No available teams': 'Aucune équipe disponible', 'No supervisors available': 'Aucun superviseur disponible', diff --git a/src/lib/dispatchVocabulary/terms.ts b/src/lib/dispatchVocabulary/terms.ts index 42a5ae36..7be54193 100644 --- a/src/lib/dispatchVocabulary/terms.ts +++ b/src/lib/dispatchVocabulary/terms.ts @@ -87,7 +87,9 @@ export const DISPATCH_TERMS: DispatchTerm[] = [ 'Call #', 'Chief Complaint', 'A/S', + 'Age/Sex', 'Status', + 'Primary Team', 'Location', 'Add Call', 'Add Team', @@ -98,6 +100,7 @@ export const DISPATCH_TERMS: DispatchTerm[] = [ 'Teams', 'Supervisors', 'No calls', + 'No clinic calls', 'No teams available', 'No available teams', 'No supervisors available', From a32c397edfd5943226a99644ca6ff7efea06aba1 Mon Sep 17 00:00:00 2001 From: evanqua Date: Wed, 12 Aug 2026 13:26:02 -0700 Subject: [PATCH 10/16] feat(dispatch): wire vocabulary into equipment card and event modals Equipment status/location controls, Mark Ready/Delete menu, staging/ delivery/notes labels, the clinic walkup modal, add/edit team and supervisor modals, and the quick-call modal now render through t(). Team-location fallbacks ('Unknown', 'Clinic') translate via the same identity-fallback t() call used elsewhere, so custom venue post names pass through untouched. Co-Authored-By: Claude Sonnet 5 --- src/components/dispatch/clinicwalkupmodal.tsx | 12 ++++--- src/components/dispatch/equipmentcard.tsx | 22 +++++++------ .../modals/event/addsupervisormodal.tsx | 14 ++++---- src/components/modals/event/addteammodal.tsx | 18 ++++++----- .../modals/event/quickcallmodal.tsx | 32 ++++++++++--------- src/lib/dispatchVocabulary/presets.ts | 14 ++++++++ src/lib/dispatchVocabulary/terms.ts | 14 ++++++++ 7 files changed, 82 insertions(+), 44 deletions(-) diff --git a/src/components/dispatch/clinicwalkupmodal.tsx b/src/components/dispatch/clinicwalkupmodal.tsx index 9cca0023..3a6c846b 100644 --- a/src/components/dispatch/clinicwalkupmodal.tsx +++ b/src/components/dispatch/clinicwalkupmodal.tsx @@ -13,6 +13,7 @@ import { } from "@heroui/react"; import { Event, Call } from "@/app/types"; +import { useDispatchTerms } from "@/lib/dispatchVocabulary/context"; type ClinicCallState = { age: string; @@ -45,6 +46,7 @@ export default function ClinicWalkupModal({ parseAgeSex, clinicId, }: Props) { + const { t } = useDispatchTerms(); const [submitting, setSubmitting] = React.useState(false); async function handleSubmit(e: React.FormEvent) { @@ -128,13 +130,13 @@ export default function ClinicWalkupModal({ {(close) => (
- Add Clinic Walkup + {t('Add Clinic Walkup')} - Cancel + {t('Cancel')} diff --git a/src/components/dispatch/equipmentcard.tsx b/src/components/dispatch/equipmentcard.tsx index 5764474a..439b60d9 100644 --- a/src/components/dispatch/equipmentcard.tsx +++ b/src/components/dispatch/equipmentcard.tsx @@ -10,6 +10,7 @@ import { import { ChevronDown, ChevronUp, MapPin, MoreVertical } from 'lucide-react'; import type { Event, EquipmentItem } from '@/app/types'; import TrackingTextEntry from '@/components/dispatch/trackingtextentry'; +import { useDispatchTerms } from '@/lib/dispatchVocabulary/context'; type EquipmentCardProps = { equipment: EquipmentItem; @@ -49,6 +50,7 @@ export default function EquipmentCard({ onDelete, updateEvent }: EquipmentCardProps) { + const { t } = useDispatchTerms(); const [expanded, setExpanded] = useState(false); const [locationInput, setLocationInput] = useState(''); const [isMobile, setIsMobile] = useState(false); @@ -127,9 +129,9 @@ export default function EquipmentCard({ }} > {equipment.needsRefresh ? ( - Mark Ready + {t('Mark Ready')} ) : null} - Delete + {t('Delete')}
@@ -156,7 +158,7 @@ export default function EquipmentCard({ }} > {statusOptions.map((s) => ( - {s} + {t(s)} ))}
@@ -211,7 +213,7 @@ export default function EquipmentCard({ const posts = (event.venue?.posts || []).map(p => (typeof p === 'string' ? p : p.name)); const teams = (event.staff || []).map(s => s.team); const opts = Array.from(new Set(['Clinic', ...posts, ...teams, equipment.stagingLocation].filter(Boolean))); - return opts.map(p => {p}); + return opts.map(p => {t(p)}); })()}
@@ -225,13 +227,13 @@ export default function EquipmentCard({ onClick={e => e.stopPropagation()} aria-hidden={!expanded} > -
Equipment Details
+
{t('Equipment Details')}
-
Staging Location: {equipment.stagingLocation || 'Not Set'}
- {equipment.callId &&
Call ID: {equipment.callId}
} - {equipment.deliveryTeam &&
Delivery Team: {equipment.deliveryTeam}
} +
{t('Staging Location')}: {equipment.stagingLocation ? t(equipment.stagingLocation) : t('Not Set')}
+ {equipment.callId &&
{t('Call ID')}: {equipment.callId}
} + {equipment.deliveryTeam &&
{t('Delivery Team')}: {t(equipment.deliveryTeam)}
}
-
Notes
+
{t('Notes')}
diff --git a/src/components/modals/event/addsupervisormodal.tsx b/src/components/modals/event/addsupervisormodal.tsx index dde02caa..aa582779 100644 --- a/src/components/modals/event/addsupervisormodal.tsx +++ b/src/components/modals/event/addsupervisormodal.tsx @@ -7,6 +7,7 @@ import { Button, Input, Select, SelectItem, } from "@heroui/react"; import { Role } from "@/app/types"; +import { useDispatchTerms } from "@/lib/dispatchVocabulary/context"; type Props = { isOpen: boolean; @@ -46,6 +47,7 @@ export default function AddSupervisorModal({ setMemberCert, roles, }: Props) { + const { t } = useDispatchTerms(); const [submitting, setSubmitting] = React.useState(false); const inputClassNames = { @@ -62,8 +64,8 @@ export default function AddSupervisorModal({ listbox: "p-1 [&_[data-hover=true]]:bg-surface-deep [&_[data-selected=true]]:bg-surface-deep", } as const; - const title = titleOverride ?? (mode === "edit" ? "Edit Supervisor" : "Add New Supervisor"); - const submitLabel = submitLabelOverride ?? (mode === "edit" ? "Save Changes" : "Create Supervisor"); + const title = titleOverride ?? (mode === "edit" ? t("Edit Supervisor") : t("Add New Supervisor")); + const submitLabel = submitLabelOverride ?? (mode === "edit" ? t("Save Changes") : t("Create Supervisor")); const canSubmit = teamName.trim().length > 0 && (memberCert?.trim().length ?? 0) > 0; @@ -91,7 +93,7 @@ export default function AddSupervisorModal({ - Lead + {t("Lead")}
{/* Cert dropdown (left) + Add button (right) */}
setQuickCall((p) => ({ ...p, location: v }))} /> { - const id = Array.from(keys)[0] as string | undefined; - if (id) setActivePresetId(id); - }} - aria-label="Dispatch vocabulary preset" - > - {availablePresets.map((preset) => ( - - {preset.name} - {preset.createdByName ? ` — by ${preset.createdByName}` : ''} - - ))} - +
+ + + +
+ + {creatingNew && ( +
+

+ Start a new shared preset from an existing one, then customize its terms. +

+
+ + +
+
+ + +
+
+ )} + + {ownPresets.length > 0 && ( +
+

Your presets

+
+ {ownPresets.map((preset) => ( +
+ {preset.name} + +
+ ))} +
+
+ )}
-
- +
+ {availablePresets.map((preset) => { + const isActive = preset.id === presetId; + const own = isOwnPreset(preset.id); + return ( +
+ - +
+ + {preset.name} + {preset.createdByName ? ( + — by {preset.createdByName} + ) : null} + +
+ + + + {own && ( + + )} +
+ ); + })}
+ + {creatingNew && (

- Start a new shared preset from an existing one, then customize its terms. + Start a new shared preset from an existing one, or from scratch, then customize its + terms.

)} - {ownPresets.length > 0 && ( -
-

Your presets

-
- {ownPresets.map((preset) => ( -
- {preset.name} - -
- ))} + {target && ( +
+
+

Editing: {target.name}

+
-
- )} - +
+ {CATEGORY_ORDER.map((category) => { + const categoryTerms = termsByCategory.get(category); + if (!categoryTerms?.length) return null; + return ( +
+

+ {DISPATCH_TERM_CATEGORY_LABELS[category]} +

+
+ {categoryTerms.map((term) => ( + + setDraftTerms((prev) => ({ ...prev, [term.key]: value })) + } + aria-label={`Label for ${term.key}`} + /> + ))} +
+
+ ); + })} +
- {editing && ( -
- {CATEGORY_ORDER.map((category) => { - const categoryTerms = termsByCategory.get(category); - if (!categoryTerms?.length) return null; - return ( -
-

- {DISPATCH_TERM_CATEGORY_LABELS[category]} -

-
- {categoryTerms.map((term) => ( + {dirty && ( +
+ {canSaveInPlace ? ( +
+

+ Save changes to "{target.name}" for everyone using it. +

+
+ + +
+
+ ) : ( + <> +

+ Editing terms creates a new preset — it won't change "{target.name} + " for anyone else. +

+
- setDraftTerms((prev) => ({ ...prev, [term.key]: value })) - } - aria-label={`Label for ${term.key}`} + value={forkName} + onValueChange={setForkName} + aria-label="New preset name" + className="flex-1" /> - ))} -
-
- ); - })} -
- )} - - {dirty && ( -
-

- Editing terms creates a new preset — it won't change "{activePreset.name} - " for anyone else. -

-
- -
- - +
+ + +
+
+ + )}
-
+ )}
)} diff --git a/src/hooks/useDispatchVocabulary.ts b/src/hooks/useDispatchVocabulary.ts index c9634368..b1b324da 100644 --- a/src/hooks/useDispatchVocabulary.ts +++ b/src/hooks/useDispatchVocabulary.ts @@ -15,6 +15,7 @@ import { createCustomPreset, deleteCustomPreset, listCustomPresets, + updateCustomPreset, } from '@/lib/dispatchVocabulary/presetsService'; import { addLocalCustomPreset, @@ -22,13 +23,16 @@ import { getLocalCustomPresets, getLocalPresetId, setLocalPresetId, + updateLocalCustomPreset, } from '@/lib/dispatchVocabulary/localStore'; +import { broadcastVocabularyChange, subscribeToVocabularyChanges } from '@/lib/dispatchVocabulary/changeBus'; export function useDispatchVocabulary() { const { user } = useAuth(); const [presetId, setPresetIdState] = useState(DEFAULT_PRESET_ID); const [customPresets, setCustomPresets] = useState([]); const [loading, setLoading] = useState(true); + const ownerId = user ? user.uid : 'local'; // Shared custom presets — visible to every signed-in user, same as org venues. useEffect(() => { @@ -75,9 +79,36 @@ export function useDispatchVocabulary() { }; }, [user]); + // Components like AppNavbar/LiteNavbar live in a persistent layout and never remount on + // client-side navigation, so their mount-time fetch above never re-runs after this user + // changes their preset elsewhere in the same session. This keeps every mounted instance + // in sync without a refetch, by listening for changes broadcast from any of them. + useEffect(() => { + return subscribeToVocabularyChanges((change) => { + switch (change.type) { + case 'selected': + setPresetIdState(change.presetId); + break; + case 'created': + case 'updated': + setCustomPresets((prev) => { + const exists = prev.some((p) => p.id === change.preset.id); + return exists + ? prev.map((p) => (p.id === change.preset.id ? change.preset : p)) + : [...prev, change.preset]; + }); + break; + case 'deleted': + setCustomPresets((prev) => prev.filter((p) => p.id !== change.id)); + break; + } + }); + }, []); + const setActivePresetId = useCallback( async (id: string) => { setPresetIdState(id); + broadcastVocabularyChange({ type: 'selected', presetId: id }); if (user) { await dbService.setDocument( 'users', @@ -113,6 +144,7 @@ export function useDispatchVocabulary() { createdAt: Date.now(), }; setCustomPresets((prev) => [...prev, preset]); + broadcastVocabularyChange({ type: 'created', preset }); await setActivePresetId(id); return id; } @@ -128,20 +160,38 @@ export function useDispatchVocabulary() { }; addLocalCustomPreset(preset); setCustomPresets((prev) => [...prev, preset]); + broadcastVocabularyChange({ type: 'created', preset }); await setActivePresetId(preset.id); return preset.id; }, [user, setActivePresetId], ); + /** Saves changes in place on a preset the current user created. Refused for built-ins and other users' presets. */ + const updatePreset = useCallback( + async (id: string, terms: Record) => { + if (isBuiltinPresetId(id)) return; + const preset = customPresets.find((p) => p.id === id); + if (!preset || preset.createdBy !== ownerId) return; + + if (user) { + await updateCustomPreset(id, terms); + } else { + updateLocalCustomPreset(id, terms); + } + const updated: DispatchVocabularyPreset = { ...preset, terms }; + setCustomPresets((prev) => prev.map((p) => (p.id === id ? updated : p))); + broadcastVocabularyChange({ type: 'updated', preset: updated }); + }, + [user, customPresets, ownerId], + ); + /** Deletes a custom preset the current user created. Built-ins and other users' presets are refused. */ const deletePreset = useCallback( async (id: string) => { if (isBuiltinPresetId(id)) return; const preset = customPresets.find((p) => p.id === id); - if (!preset) return; - const owner = user ? user.uid : 'local'; - if (preset.createdBy !== owner) return; + if (!preset || preset.createdBy !== ownerId) return; if (user) { await deleteCustomPreset(id); @@ -149,13 +199,19 @@ export function useDispatchVocabulary() { deleteLocalCustomPreset(id); } setCustomPresets((prev) => prev.filter((p) => p.id !== id)); + broadcastVocabularyChange({ type: 'deleted', id }); if (presetId === id) { await setActivePresetId(DEFAULT_PRESET_ID); } }, - [user, customPresets, presetId, setActivePresetId], + [user, customPresets, ownerId, presetId, setActivePresetId], ); + const isOwnPreset = useCallback((id: string) => { + const preset = customPresets.find((p) => p.id === id); + return !!preset && preset.createdBy === ownerId; + }, [customPresets, ownerId]); + const availablePresets: DispatchVocabularyPresetSummary[] = useMemo( () => [BUILTIN_PRESETS[DEFAULT_PRESET_ID], BUILTIN_PRESETS[FRENCH_PRESET_ID], ...customPresets], [customPresets], @@ -175,7 +231,9 @@ export function useDispatchVocabulary() { customPresets, setActivePresetId, forkPreset, + updatePreset, deletePreset, + isOwnPreset, loading, }; } diff --git a/src/lib/dispatchVocabulary/changeBus.ts b/src/lib/dispatchVocabulary/changeBus.ts new file mode 100644 index 00000000..929bdfc9 --- /dev/null +++ b/src/lib/dispatchVocabulary/changeBus.ts @@ -0,0 +1,28 @@ +import type { DispatchVocabularyPreset } from './types'; + +/** + * Every mounted useDispatchVocabulary() instance does its own one-time fetch on mount + * (there's no realtime Firestore subscription here). Components that live in a persistent + * layout — AppNavbar, LiteNavbar — never remount on client-side navigation, so without this + * bus they'd stay frozen at whatever preset was active when the app first loaded, even after + * the user changes it elsewhere in the same session. Every mutation broadcasts here so every + * other instance can update its own local state immediately, no refetch required. + */ +type VocabularyChange = + | { type: 'selected'; presetId: string } + | { type: 'created'; preset: DispatchVocabularyPreset } + | { type: 'updated'; preset: DispatchVocabularyPreset } + | { type: 'deleted'; id: string }; + +type Listener = (change: VocabularyChange) => void; + +const listeners = new Set(); + +export function subscribeToVocabularyChanges(fn: Listener): () => void { + listeners.add(fn); + return () => listeners.delete(fn); +} + +export function broadcastVocabularyChange(change: VocabularyChange) { + listeners.forEach((fn) => fn(change)); +} diff --git a/src/lib/dispatchVocabulary/localStore.ts b/src/lib/dispatchVocabulary/localStore.ts index af8a0a46..cd065640 100644 --- a/src/lib/dispatchVocabulary/localStore.ts +++ b/src/lib/dispatchVocabulary/localStore.ts @@ -46,3 +46,15 @@ export function deleteLocalCustomPreset(id: string) { // localStorage unavailable — nothing to clean up } } + +export function updateLocalCustomPreset(id: string, terms: Record) { + try { + const existing = getLocalCustomPresets(); + localStorage.setItem( + LOCAL_PRESETS_KEY, + JSON.stringify(existing.map((p) => (p.id === id ? { ...p, terms } : p))), + ); + } catch { + // localStorage unavailable — edit only lives in memory this session + } +} diff --git a/src/lib/dispatchVocabulary/presetsService.ts b/src/lib/dispatchVocabulary/presetsService.ts index beb2611a..234fd9da 100644 --- a/src/lib/dispatchVocabulary/presetsService.ts +++ b/src/lib/dispatchVocabulary/presetsService.ts @@ -22,16 +22,23 @@ export async function createCustomPreset(input: { createdByName?: string; basedOn?: string; }): Promise { - return dbService.addDocument>(COLLECTION, { + const payload: Omit = { name: input.name, terms: input.terms, createdBy: input.createdBy, - createdByName: input.createdByName, - basedOn: input.basedOn, createdAt: Date.now(), - }); + }; + // Firestore rejects explicit `undefined` field values — only include optional fields when set. + if (input.createdByName) payload.createdByName = input.createdByName; + if (input.basedOn) payload.basedOn = input.basedOn; + + return dbService.addDocument>(COLLECTION, payload); } export async function deleteCustomPreset(id: string): Promise { await dbService.deleteDocument(COLLECTION, id); } + +export async function updateCustomPreset(id: string, terms: Record): Promise { + await dbService.setDocument(COLLECTION, id, { terms }, { merge: true }); +} From 7855763cf1d3a4d8f442d85cc32699e4ac809e95 Mon Sep 17 00:00:00 2001 From: evanqua Date: Wed, 12 Aug 2026 15:03:23 -0700 Subject: [PATCH 14/16] fix(profile): cap list heights with minimal scrollbar, clean up preset select - Add max-height + minimal-scrollbar to org venues, users, and venue selection lists so they scroll instead of growing unbounded - Remove the selected-item background behind the "Based on" preset checkmark and move its label outside the input box --- src/app/(main)/venues/selection/page.tsx | 4 ++-- src/components/profile/admin-users-section.tsx | 2 +- src/components/profile/admin-venues-section.tsx | 2 +- src/components/profile/language-section.tsx | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/(main)/venues/selection/page.tsx b/src/app/(main)/venues/selection/page.tsx index 667dd510..8326ebcd 100644 --- a/src/app/(main)/venues/selection/page.tsx +++ b/src/app/(main)/venues/selection/page.tsx @@ -374,7 +374,7 @@ export default function VenueSelection() { }} /> - + {filteredVenues.map((venue) => { const stats = venueStats.byVenue[venue.id] ?? { count: 0, lastUsed: null }; return ( @@ -659,7 +659,7 @@ export default function VenueSelection() { }} /> - + {filteredVenues.map((venue) => { const stats = venueStats.byVenue[venue.id] ?? { count: 0, lastUsed: null }; const isSelected = selectedVenueId === venue.id; diff --git a/src/components/profile/admin-users-section.tsx b/src/components/profile/admin-users-section.tsx index d6397b4c..ac342a70 100644 --- a/src/components/profile/admin-users-section.tsx +++ b/src/components/profile/admin-users-section.tsx @@ -131,7 +131,7 @@ export default function AdminUsersSection({ currentUser }: { currentUser: Servic {loading ? ( ) : ( -
+
{filtered.map((row) => (
) : ( -
+
{filtered.map((venue) => (