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..3acb0296 100644 --- a/firestore.rules +++ b/firestore.rules @@ -21,22 +21,51 @@ 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(); } - // Users: allow users to read/write their own profile + // 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 + // 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..1dfcfbfa 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} ...`); @@ -120,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' }, @@ -150,10 +181,52 @@ 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/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, 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" + + "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..ff39c336 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"; @@ -31,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' }]; @@ -72,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); @@ -100,7 +106,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; @@ -2804,10 +2810,10 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { @@ -2834,8 +2840,8 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { } }} > - Add Team - Add Supervisor + {t('Add Team')} + {t('Add Supervisor')} @@ -2957,7 +2963,7 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { ); return ( - <> + {/* All your modals first - unchanged */} - Teams - Supervisors - Equipment + {t('Teams')} + {t('Supervisors')} + {t('Equipment')} @@ -3142,7 +3148,7 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { ))} {(!event?.staff || event.staff.length === 0) && (
- No teams available + {t('No teams available')}
)} @@ -3221,7 +3227,7 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { ) : (
- No equipment configured + {t('No equipment configured')}
)} @@ -3245,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) => ( @@ -3264,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}

+
@@ -3314,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}

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

Teams

+

{t('Teams')}

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

Supervisors

+

{t('Supervisors')}

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

Equipment

+

{t('Equipment')}

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

Calls

+

{t('Calls')}

- +
-
@@ -3611,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')}
@@ -3710,7 +3716,7 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { ))} {getClinicCalls(clinic.id).length === 0 && (
- No clinic calls + {t('No clinic calls')}
)}
@@ -3720,7 +3726,7 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) { className="text-surface-faint text-base hover:text-surface-light" aria-label="Toggle resolved clinic calls" > - {showResolvedClinicCalls ? 'Hide Resolved Clinic Calls' : 'Show Resolved Clinic Calls'} + {showResolvedClinicCalls ? t('Hide Resolved Clinic Calls') : t('Show Resolved Clinic Calls')}
@@ -3843,6 +3849,6 @@ export default function DispatchPage({ params }: DispatchRoutePageProps) {
)} - + ); } diff --git a/src/app/(main)/venues/selection/page.tsx b/src/app/(main)/venues/selection/page.tsx index 7b989387..6246e10e 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 = {}; @@ -366,7 +374,7 @@ export default function VenueSelection() { }} /> - + {filteredVenues.map((venue) => { const stats = venueStats.byVenue[venue.id] ?? { count: 0, lastUsed: null }; return ( @@ -383,7 +391,12 @@ export default function VenueSelection() {
-

{venue.name}

+

{venue.name}

+ {venue.isOrgVenue && ( + + Org + + )}
{stats.count} {stats.count === 1 ? 'event' : 'events'} @@ -494,7 +507,7 @@ export default function VenueSelection() { ) : ( -
+
{selectedVenueEvents.map((event) => ( - + {filteredVenues.map((venue) => { const stats = venueStats.byVenue[venue.id] ?? { count: 0, lastUsed: null }; const isSelected = selectedVenueId === venue.id; @@ -669,7 +682,12 @@ export default function VenueSelection() {
-

{venue.name}

+

{venue.name}

+ {venue.isOrgVenue && ( + + Org + + )}
{stats.count} {stats.count === 1 ? 'event' : 'events'} @@ -779,7 +797,7 @@ export default function VenueSelection() {
) : ( -
+
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/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/dispatch/calltracking.tsx b/src/components/dispatch/calltracking.tsx index 80680b15..f9f673a5 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); @@ -379,9 +381,9 @@ export const CallTrackingTable: React.FC = ({ size="lg" variant="flat" color="default" - className="text-surface-light h-9 shrink-0 border border-surface-liner bg-surface-liner/30" + className="text-surface-light h-8 shrink-0 border border-surface-liner bg-surface-liner/30" > - Pending + {t('Pending')} )} {/* Active assigned teams - Larger chips with centered dropdown */} @@ -399,7 +401,7 @@ export const CallTrackingTable: React.FC = ({ size="lg" variant="flat" color="default" - className={`text-surface-light h-9 shrink-0 ${teamStatusColor.chipClass}`} + className={`text-surface-light h-8 shrink-0 ${teamStatusColor.chipClass}`} onClose={() => handleRemoveTeamFromCall(call.id, team)} >
@@ -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)} ))} @@ -457,13 +459,13 @@ export const CallTrackingTable: React.FC = ({ size="lg" variant="flat" color={detachedTeam.reason === 'Delivered' ? 'success' : 'default'} - className="border border-surface-liner h-9" + className="border border-surface-liner h-8" > {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')} )} @@ -1288,10 +1290,8 @@ export const CallTrackingTable: React.FC = ({ {/* Options Ellipsis */} -
{/* 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" />
@@ -596,7 +598,7 @@ export default function ClinicTrackingTable({ className="text-surface-faint text-base hover:text-surface-light" aria-label="Toggle resolved clinic calls" > - {showResolvedClinicCalls ? 'Hide Resolved Clinic Calls' : 'Show Resolved Clinic Calls'} + {showResolvedClinicCalls ? t('Hide Resolved Clinic Calls') : t('Show Resolved Clinic Calls')} 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/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/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/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({
- - - - - {showStatusColumn && } - + + + + + {showStatusColumn && } + diff --git a/src/components/layout/appnavbar.tsx b/src/components/layout/appnavbar.tsx index 6a94d5df..cce810ad 100644 --- a/src/components/layout/appnavbar.tsx +++ b/src/components/layout/appnavbar.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useState } from "react"; import dynamic from "next/dynamic"; import { authService } from "@/lib/services"; import { useAuth } from "@/hooks/useauth"; +import { useDispatchVocabulary } from "@/hooks/useDispatchVocabulary"; import { Navbar, @@ -44,6 +45,11 @@ export default function AppNavbar() { const router = useRouter(); const pathname = usePathname(); const { user, ready } = useAuth(); + const { activePreset: dispatchVocabularyPreset } = useDispatchVocabulary(); + const t = useCallback( + (key: string) => dispatchVocabularyPreset.terms[key] ?? key, + [dispatchVocabularyPreset] + ); const [isMenuOpen, setIsMenuOpen] = useState(false); const [loginOpen, setLoginOpen] = useState(false); @@ -175,7 +181,7 @@ export default function AppNavbar() { itemActive ? "text-surface-light" : "text-surface-light hover:text-accent" }`} > - {label} + {t(label)} )) @@ -297,7 +303,7 @@ export default function AppNavbar() { }} className="block w-full text-left text-[18px] px-2 py-2 hover:opacity-80" > - {label} + {t(label)} )) diff --git a/src/components/layout/litenavbar.tsx b/src/components/layout/litenavbar.tsx index 2fd0457a..c87bdf97 100644 --- a/src/components/layout/litenavbar.tsx +++ b/src/components/layout/litenavbar.tsx @@ -7,6 +7,7 @@ import { usePathname, useRouter } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; import { authService } from '@/lib/services'; import { useAuth } from '@/hooks/useauth'; +import { useDispatchVocabulary } from '@/hooks/useDispatchVocabulary'; import { getLiteEvent } from '@/lib/liteEventStore'; import { Avatar, @@ -62,6 +63,11 @@ export default function LiteNavbar() { const router = useRouter(); const pathname = usePathname(); const { user, ready } = useAuth(); + const { activePreset: dispatchVocabularyPreset } = useDispatchVocabulary(); + const t = useCallback( + (key: string) => dispatchVocabularyPreset.terms[key] ?? key, + [dispatchVocabularyPreset] + ); const [isMenuOpen, setIsMenuOpen] = useState(false); const [loginOpen, setLoginOpen] = useState(false); const [loginMode, setLoginMode] = useState<'login' | 'signup'>('login'); @@ -199,7 +205,7 @@ export default function LiteNavbar() { onClick={openPostingSchedule} className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-sm font-medium transition text-surface-light hover:text-accent" > - Posting Schedule + {t('Posting Schedule')} )} @@ -211,7 +217,7 @@ export default function LiteNavbar() { type="button" className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-sm font-medium transition text-surface-light hover:text-accent" > - End Event + {t('End Event')} @@ -220,10 +226,10 @@ export default function LiteNavbar() { className="text-status-red" onPress={triggerClearEvent} > - Clear Event + {t('Clear Event')} - Export Summary + {t('Export Summary')} @@ -346,7 +352,7 @@ export default function LiteNavbar() { }} className="block w-full text-left text-[18px] px-2 py-2" > - Posting Schedule + {t('Posting Schedule')} )} @@ -358,7 +364,7 @@ export default function LiteNavbar() { }} className="block w-full text-left text-[18px] px-2 py-2 text-status-red" > - Clear Event + {t('Clear Event')} @@ -369,7 +375,7 @@ export default function LiteNavbar() { }} className="block w-full text-left text-[18px] px-2 py-2" > - Export Summary + {t('Export Summary')} 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/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 }))} /> 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..0a5fc5ad --- /dev/null +++ b/src/components/profile/admin-section.tsx @@ -0,0 +1,22 @@ +'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'; +import AdminVenuesSection from './admin-venues-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..ac342a70 --- /dev/null +++ b/src/components/profile/admin-users-section.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Button, Card, CardBody, Input, Spinner, Switch } from '@heroui/react'; +import { RefreshCw, Trash2 } from 'lucide-react'; +import { dbService, isPocketbaseBackend } 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), + }; +} + +/** 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); + 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); + } + }; + + 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 Administrator Access

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

} +
+ )} +
+ + {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..ce747fa4 --- /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/language-section.tsx b/src/components/profile/language-section.tsx new file mode 100644 index 00000000..a7bbab5f --- /dev/null +++ b/src/components/profile/language-section.tsx @@ -0,0 +1,418 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Card, CardBody, Select, SelectItem, Input, Button } from '@heroui/react'; +import { Check, Circle, Pencil, Trash2 } from 'lucide-react'; +import { useDispatchVocabulary } from '@/hooks/useDispatchVocabulary'; +import { + DISPATCH_TERMS, + DISPATCH_TERM_CATEGORY_LABELS, + type DispatchTermCategory, +} from '@/lib/dispatchVocabulary/terms'; + +const BLANK_TEMPLATE_ID = '__blank__'; + +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', +} as const; + +const CATEGORY_ORDER: DispatchTermCategory[] = [ + 'entities', + 'callStatuses', + 'teamStatuses', + 'equipment', + 'clinicOutcomes', + 'actions', +]; + +export default function LanguageSection() { + const { + presetId, + availablePresets, + setActivePresetId, + forkPreset, + updatePreset, + deletePreset, + isOwnPreset, + loading, + } = useDispatchVocabulary(); + + const [editingTargetId, setEditingTargetId] = useState(null); + const [draftTerms, setDraftTerms] = useState>({}); + const [forkName, setForkName] = useState(''); + const [saving, setSaving] = useState(false); + const [deletingId, setDeletingId] = useState(null); + + const [creatingNew, setCreatingNew] = useState(false); + const [newPresetBaseId, setNewPresetBaseId] = useState(presetId); + const [newPresetName, setNewPresetName] = useState(''); + const [creating, setCreating] = useState(false); + + const target = useMemo( + () => availablePresets.find((p) => p.id === editingTargetId) ?? null, + [availablePresets, editingTargetId], + ); + + // Load the editor's draft whenever the edit target changes. + useEffect(() => { + setDraftTerms(target?.terms ?? {}); + setForkName(''); + }, [target]); + + const canSaveInPlace = editingTargetId ? isOwnPreset(editingTargetId) : false; + + const dirty = useMemo(() => { + if (!target) return false; + return DISPATCH_TERMS.some((t) => (draftTerms[t.key] ?? '') !== (target.terms[t.key] ?? '')); + }, [draftTerms, target]); + + 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 handleSave = async () => { + if (!target) return; + setSaving(true); + try { + if (canSaveInPlace) { + await updatePreset(target.id, draftTerms); + } else { + const name = forkName.trim(); + if (!name) return; + const newId = await forkPreset(name, draftTerms, target.id); + setEditingTargetId(newId); + } + } finally { + setSaving(false); + } + }; + + const handleCreateFromTemplate = async () => { + const name = newPresetName.trim(); + if (!name) return; + setCreating(true); + try { + const terms = + newPresetBaseId === BLANK_TEMPLATE_ID + ? {} + : availablePresets.find((p) => p.id === newPresetBaseId)?.terms ?? {}; + const basedOn = newPresetBaseId === BLANK_TEMPLATE_ID ? undefined : newPresetBaseId; + const newId = await forkPreset(name, terms, basedOn); + setCreatingNew(false); + setNewPresetName(''); + setEditingTargetId(newId); + } finally { + setCreating(false); + } + }; + + const handleDeletePreset = async (id: string, name: string) => { + if (!confirm(`Delete the preset "${name}"? This can't be undone.`)) return; + setDeletingId(id); + try { + await deletePreset(id); + if (editingTargetId === id) setEditingTargetId(null); + } finally { + setDeletingId(null); + } + }; + + return ( + + +
+

Dispatch language

+

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

+

+ This only changes what you see in the dispatch view — other dispatchers pick their + own preset independently. Activity logs are always recorded in English, regardless + of preset. +

+
+ +
+ {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, or from scratch, then customize its + terms. +

+
+ + +
+
+ + +
+
+ )} + + {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}`} + /> + ))} +
+
+ ); + })} +
+ + {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. +

+
+ +
+ + +
+
+ + )} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/profile/preferences-section.tsx b/src/components/profile/preferences-section.tsx new file mode 100644 index 00000000..db56c38c --- /dev/null +++ b/src/components/profile/preferences-section.tsx @@ -0,0 +1,31 @@ +'use client'; + +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(); + + 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..5cad169c --- /dev/null +++ b/src/components/profile/security-section.tsx @@ -0,0 +1,358 @@ +'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" + classNames={{ + inputWrapper: + 'group-data-[focus=true]:ring-0 group-data-[focus-visible=true]:ring-0 group-data-[focus-visible=true]:ring-offset-0', + input: 'outline-none focus:outline-none data-[focus=true]:outline-none focus:ring-0 focus-visible:ring-0', + }} + /> +
+ + +
+ {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/useDispatchVocabulary.ts b/src/hooks/useDispatchVocabulary.ts new file mode 100644 index 00000000..b1b324da --- /dev/null +++ b/src/hooks/useDispatchVocabulary.ts @@ -0,0 +1,239 @@ +'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, + deleteCustomPreset, + listCustomPresets, + updateCustomPreset, +} from '@/lib/dispatchVocabulary/presetsService'; +import { + addLocalCustomPreset, + deleteLocalCustomPreset, + 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(() => { + 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]); + + // 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', + 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]); + broadcastVocabularyChange({ type: 'created', 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]); + 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 || preset.createdBy !== ownerId) return; + + if (user) { + await deleteCustomPreset(id); + } else { + deleteLocalCustomPreset(id); + } + setCustomPresets((prev) => prev.filter((p) => p.id !== id)); + broadcastVocabularyChange({ type: 'deleted', id }); + if (presetId === id) { + await setActivePresetId(DEFAULT_PRESET_ID); + } + }, + [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], + ); + + 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, + updatePreset, + deletePreset, + isOwnPreset, + loading, + }; +} 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/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/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/localStore.ts b/src/lib/dispatchVocabulary/localStore.ts new file mode 100644 index 00000000..cd065640 --- /dev/null +++ b/src/lib/dispatchVocabulary/localStore.ts @@ -0,0 +1,60 @@ +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 + } +} + +export function deleteLocalCustomPreset(id: string) { + try { + const existing = getLocalCustomPresets(); + localStorage.setItem(LOCAL_PRESETS_KEY, JSON.stringify(existing.filter((p) => p.id !== id))); + } catch { + // 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/presets.ts b/src/lib/dispatchVocabulary/presets.ts new file mode 100644 index 00000000..79e5815d --- /dev/null +++ b/src/lib/dispatchVocabulary/presets.ts @@ -0,0 +1,170 @@ +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', + 'Age/Sex': 'A/S', + Status: 'Statut', + 'Primary Team': 'Équipe principale', + 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 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', + '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', + 'Show Resolved Clinic Calls': 'Afficher les appels résolus du poste de secours', + 'Hide Resolved Clinic Calls': 'Masquer les appels résolus du poste de secours', + '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', + Notes: 'Notes', + 'Add notes': 'Ajouter des notes', + 'Add notes about this equipment': 'Ajouter des notes sur cet équipement', + 'Not Set': 'Non défini', + 'Call ID': "ID d'appel", + '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é', + 'Add Clinic Walkup': 'Ajouter une présentation spontanée', + Cancel: 'Annuler', + Submit: 'Envoyer', + 'Member name': 'Nom du membre', + 'Add member': 'Ajouter un membre', + 'Save Changes': 'Enregistrer les modifications', + 'Create Team': "Créer l'équipe", + 'Create Supervisor': 'Créer le superviseur', + Source: 'Source', + 'Assign Team': 'Assigner une équipe', + 'Select a team': 'Sélectionner une équipe', + 'Venue Map': 'Plan du site', + 'Posting Schedule': 'Horaire des postes', + 'End Event': "Terminer l'événement", + Venues: 'Sites', + 'Clear Event': "Réinitialiser l'événement", + 'Export Summary': 'Exporter le résumé', +}; + +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/presetsService.ts b/src/lib/dispatchVocabulary/presetsService.ts new file mode 100644 index 00000000..234fd9da --- /dev/null +++ b/src/lib/dispatchVocabulary/presetsService.ts @@ -0,0 +1,44 @@ +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 { + const payload: Omit = { + name: input.name, + terms: input.terms, + createdBy: input.createdBy, + 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 }); +} diff --git a/src/lib/dispatchVocabulary/terms.ts b/src/lib/dispatchVocabulary/terms.ts new file mode 100644 index 00000000..76a8095c --- /dev/null +++ b/src/lib/dispatchVocabulary/terms.ts @@ -0,0 +1,180 @@ +/** + * 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', + 'Age/Sex', + 'Status', + 'Primary Team', + 'Location', + 'Add Call', + 'Add Team', + 'Add Supervisor', + 'Add Equipment', + 'Add Patient', + 'Calls', + 'Teams', + 'Supervisors', + 'No calls', + 'No clinic 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', + 'Show Resolved Clinic Calls', + 'Hide Resolved Clinic Calls', + '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', + 'Notes', + 'Add notes', + 'Add notes about this equipment', + 'Not Set', + 'Call ID', + 'No log entries', + 'Log for Call', + 'PRIORITY CALL: Life threat to patient/provider', + 'Edit', + 'Delete', + 'No members', + 'Activity Log', + 'Add Clinic Walkup', + 'Cancel', + 'Submit', + 'Member name', + 'Add member', + 'Save Changes', + 'Create Team', + 'Create Supervisor', + 'Source', + 'Assign Team', + 'Select a team', + 'Venue Map', + 'Posting Schedule', + 'End Event', + 'Venues', + 'Clear Event', + 'Export Summary', + ]), +]; + +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; +} 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..c86e7b49 --- /dev/null +++ b/src/lib/userDoc.ts @@ -0,0 +1,10 @@ +/** 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; + /** Built-in preset id ('crowdcad-default' | 'crowdcad-french') or a dispatchVocabularyPresets doc id. */ + dispatchVocabularyPresetId?: string; +} 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/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'), 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(); });
- + + = ({ > 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/clinictracking.tsx b/src/components/dispatch/clinictracking.tsx index 4565055c..13a3567e 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'))}
Call #Chief ComplaintA/SLocationStatusTeam{t('Call #')}{t('Chief Complaint')}{t('A/S')}{t('Location')}{t('Status')}{t('Team')}