From 518e38e608a1571bf07163aa0805812c274d474b Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 23 Sep 2026 07:52:16 -0700 Subject: [PATCH 1/5] RG-T55 Fixes, Contacts, Ops --- .../operations/__tests__/operations.test.ts | 64 +++- src/api/operations/operations.ts | 39 +- src/app/(app)/home.tsx | 33 +- src/app/(app)/home.web.tsx | 44 +-- src/app/(app)/operations/[id].tsx | 163 +++++++-- src/app/(app)/records.tsx | 2 +- src/app/call/[id].tsx | 11 +- src/app/call/[id].web.tsx | 15 +- .../__tests__/activity-link-marker.test.tsx | 60 ++++ src/components/calls/activity-link-marker.tsx | 80 +++++ .../__tests__/contact-details-sheet.test.tsx | 1 + .../contacts/contact-details-extra.tsx | 120 +++++++ .../contacts/contact-details-sheet.tsx | 23 +- .../contacts/contact-files-list.tsx | 24 +- .../activity-log-panel-actions.test.tsx | 114 ++++++ .../activity-log-panel-link-markers.test.tsx | 89 +++++ .../personnel-actions-panel.test.tsx | 153 ++++++++ .../__tests__/unit-actions-panel.test.tsx | 221 ++++++++++++ .../dispatch-console/activity-log-panel.tsx | 36 +- .../personnel-actions-panel.tsx | 186 ++++++---- .../dispatch-console/unit-actions-panel.tsx | 184 ++++++---- .../notifications/NotificationDetail.tsx | 12 +- .../notifications/NotificationInbox.tsx | 70 ++-- .../notification-references.test.tsx | 106 ++++++ src/components/operations/expenses-panel.tsx | 154 ++++++++ src/components/operations/scope-picker.tsx | 102 ++++++ .../operations/time-report-editor.tsx | 277 +++++++++++---- .../__tests__/use-set-status-for-call.test.ts | 144 ++++++++ src/hooks/use-set-status-for-call.ts | 67 ++++ src/lib/__tests__/activity-link.test.ts | 42 +++ src/lib/__tests__/destination-helpers.test.ts | 66 ++++ src/lib/__tests__/unit-status-helpers.test.ts | 56 +++ src/lib/activity-link.ts | 46 +++ src/lib/contacts/__tests__/format.test.ts | 27 ++ src/lib/contacts/format.ts | 64 ++++ src/lib/destination-helpers.ts | 94 +++++ src/lib/media/photo.ts | 47 +++ .../__tests__/inbox-reference.test.ts | 34 ++ src/lib/notifications/inbox-reference.ts | 32 ++ src/lib/operations/__tests__/time.test.ts | 125 +++++-- src/lib/operations/capabilities.ts | 7 +- src/lib/operations/time.ts | 211 ++++++++--- src/lib/unit-status-helpers.ts | 19 +- src/models/v4/calls/callExtraDataResult.ts | 33 +- .../v4/calls/dispatchedEventResultData.ts | 9 + src/models/v4/contacts/contactResultData.ts | 29 ++ src/models/v4/operations/index.ts | 76 +++- src/stores/calls/detail-store.ts | 2 +- src/stores/contacts/store.ts | 42 ++- .../__tests__/personnel-actions-store.test.ts | 96 +++++ .../__tests__/unit-actions-store.test.ts | 122 +++++++ .../dispatch/personnel-actions-store.ts | 81 +++-- src/stores/dispatch/unit-actions-store.ts | 75 ++-- src/stores/operations/__tests__/store.test.ts | 96 ++++- src/stores/operations/store.ts | 336 +++++++++++------- src/translations/ar.json | 105 +++++- src/translations/de.json | 105 +++++- src/translations/el.json | 105 +++++- src/translations/en.json | 103 +++++- src/translations/es.json | 105 +++++- src/translations/fr.json | 105 +++++- src/translations/it.json | 105 +++++- src/translations/pl.json | 105 +++++- src/translations/sv.json | 105 +++++- src/translations/uk.json | 105 +++++- src/types/notification.ts | 2 +- 66 files changed, 4752 insertions(+), 759 deletions(-) create mode 100644 src/components/calls/__tests__/activity-link-marker.test.tsx create mode 100644 src/components/calls/activity-link-marker.tsx create mode 100644 src/components/contacts/contact-details-extra.tsx create mode 100644 src/components/dispatch-console/__tests__/activity-log-panel-actions.test.tsx create mode 100644 src/components/dispatch-console/__tests__/activity-log-panel-link-markers.test.tsx create mode 100644 src/components/dispatch-console/__tests__/personnel-actions-panel.test.tsx create mode 100644 src/components/dispatch-console/__tests__/unit-actions-panel.test.tsx create mode 100644 src/components/notifications/__tests__/notification-references.test.tsx create mode 100644 src/components/operations/expenses-panel.tsx create mode 100644 src/components/operations/scope-picker.tsx create mode 100644 src/hooks/__tests__/use-set-status-for-call.test.ts create mode 100644 src/hooks/use-set-status-for-call.ts create mode 100644 src/lib/__tests__/activity-link.test.ts create mode 100644 src/lib/__tests__/unit-status-helpers.test.ts create mode 100644 src/lib/activity-link.ts create mode 100644 src/lib/contacts/__tests__/format.test.ts create mode 100644 src/lib/contacts/format.ts create mode 100644 src/lib/media/photo.ts create mode 100644 src/lib/notifications/__tests__/inbox-reference.test.ts create mode 100644 src/lib/notifications/inbox-reference.ts create mode 100644 src/stores/dispatch/__tests__/personnel-actions-store.test.ts create mode 100644 src/stores/dispatch/__tests__/unit-actions-store.test.ts diff --git a/src/api/operations/__tests__/operations.test.ts b/src/api/operations/__tests__/operations.test.ts index a60a5434..118c26f1 100644 --- a/src/api/operations/__tests__/operations.test.ts +++ b/src/api/operations/__tests__/operations.test.ts @@ -1,11 +1,26 @@ -jest.mock('@/api/common/client', () => ({ api: { get: jest.fn(), post: jest.fn() } })); +jest.mock('@/api/common/client', () => ({ api: { get: jest.fn(), post: jest.fn(), delete: jest.fn() } })); import { api } from '@/api/common/client'; -import { addResourceUsage, buildF42, getDeployments, getTimeReports, newTimeReport, saveTimeEntries, submitTimeReport, validateCalOesMarsWorkItem } from '@/api/operations/operations'; +import { + addResourceUsage, + approveTimeReport, + buildF42, + deleteExpense, + getDeployments, + getExpenses, + getTimeReports, + newTimeReport, + saveExpense, + saveTimeEntries, + signTimeReport, + submitTimeReport, + validateCalOesMarsWorkItem, +} from '@/api/operations/operations'; beforeEach(() => { jest.clearAllMocks(); jest.mocked(api.get).mockResolvedValue({ data: { Data: [] } }); jest.mocked(api.post).mockResolvedValue({ data: { Data: { Id: 'r-1', Entries: [] }, Errors: [], Warnings: [] } }); + jest.mocked(api.delete).mockResolvedValue({ data: { Status: 'Success' } }); }); it('lists open deployments by default and everything on request', async () => { @@ -17,15 +32,50 @@ it('lists open deployments by default and everything on request', async () => { expect(api.get).toHaveBeenLastCalledWith('/TimeReports/GetTimeReports', { params: { deploymentId: 'dep-1' } }); }); -it('creates, saves and submits a daily time report with the whole entry list as the unit of save', async () => { - await newTimeReport('dep-1', '2026-09-19'); - expect(api.post).toHaveBeenCalledWith('/TimeReports/NewTimeReport', { DeploymentId: 'dep-1', ReportDate: '2026-09-19' }); - const entry = { Id: null, SubjectType: 0, DeploymentPersonnelId: 'p-1', EntryType: 0, StartTime: '2026-09-19T08:00:00', EndTime: '2026-09-19T18:00:00', PaidBreakMinutes: 0, UnpaidBreakMinutes: 30, AgencySuppliedMeals: true, AgencySuppliedAccommodation: false, SortOrder: 0 }; +it('opens crew and individual reports and saves only the local wall clock', async () => { + await newTimeReport('dep-1', '2026-09-19', { deploymentUnitId: 'du-1' }); + expect(api.post).toHaveBeenCalledWith('/TimeReports/NewTimeReport', { DeploymentId: 'dep-1', ReportDate: '2026-09-19', DeploymentUnitId: 'du-1', DeploymentPersonnelId: null }); + await newTimeReport('dep-1', '2026-09-19', { deploymentPersonnelId: 'dp-5' }); + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/NewTimeReport', { DeploymentId: 'dep-1', ReportDate: '2026-09-19', DeploymentUnitId: null, DeploymentPersonnelId: 'dp-5' }); + + const entry = { + Id: 'e-1', + SubjectType: 0, + DeploymentPersonnelId: 'p-1', + EntryType: 0, + StartTime: '2026-09-19T15:00:00.000Z', + EndTime: '2026-09-20T01:00:00.000Z', + StartLocal: '2026-09-19T08:00', + EndLocal: '2026-09-19T18:00', + PaidBreakMinutes: 0, + UnpaidBreakMinutes: 30, + AgencySuppliedMeals: true, + AgencySuppliedAccommodation: false, + SortOrder: 0, + Hours: 9.5, + }; const saved = await saveTimeEntries('r-1', [entry]); - expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SaveTimeEntries', { TimeReportId: 'r-1', Entries: [entry] }); + const { StartTime: _start, EndTime: _end, Hours: _hours, ...sent } = entry; + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SaveTimeEntries', { TimeReportId: 'r-1', Entries: [sent] }); expect(saved.Errors).toEqual([]); await submitTimeReport('r-1'); expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SubmitTimeReport', { Id: 'r-1' }); + await signTimeReport('r-1', true); + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SignTimeReport', { Id: 'r-1', ContractorSigned: true, CustomerSignerName: null }); + await signTimeReport('r-1', false, 'J. Agency'); + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SignTimeReport', { Id: 'r-1', ContractorSigned: false, CustomerSignerName: 'J. Agency' }); + await approveTimeReport('r-1'); + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/ApproveTimeReport', { Id: 'r-1' }); +}); + +it('files, lists and removes expenses with the receipt riding as base64', async () => { + await getExpenses('dep-1'); + expect(api.get).toHaveBeenLastCalledWith('/TimeReports/GetExpenses', { params: { deploymentId: 'dep-1' } }); + const input = { DeploymentId: 'dep-1', ExpenseDate: '2026-09-19', ExpenseType: 4, Amount: 88.4, ReceiptData: 'AAAA', ReceiptFileName: 'r.jpg', ReceiptContentType: 'image/jpeg' }; + await saveExpense(input); + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SaveExpense', input, { timeout: 60000 }); + await deleteExpense('x-1'); + expect(api.delete).toHaveBeenCalledWith('/TimeReports/DeleteExpense', { params: { id: 'x-1' } }); }); it('posts usage readings and the F-42 draft / validation calls as the server expects them', async () => { diff --git a/src/api/operations/operations.ts b/src/api/operations/operations.ts index 0e1753e7..10352c8e 100644 --- a/src/api/operations/operations.ts +++ b/src/api/operations/operations.ts @@ -6,6 +6,8 @@ import type { CalOesMarsWorkItem, Deployment, DeploymentAccess, + Expense, + ExpenseInput, FieldCostAccess, OperationsResult, ResourceUsage, @@ -22,12 +24,43 @@ export const getDeployments = async (active = true) => (await api.get (await api.get>('/Deployments/GetDeployment', { params: { id } })).data.Data; export const getDeploymentByCallId = async (callId: number) => (await api.get>('/Deployments/GetDeploymentByCallId', { params: { callId } })).data.Data; +/** Who a new report covers: one deployed unit's crew (CTR), one person, or — managers only — the whole deployment. */ +export interface TimeReportScopeInput { + deploymentUnitId?: string | null; + deploymentPersonnelId?: string | null; +} + +// Reports come back with their entries so a day's report opens straight from the list. export const getTimeReports = async (deploymentId: string) => (await api.get>('/TimeReports/GetTimeReports', { params: { deploymentId } })).data.Data; export const getTimeReport = async (id: string) => (await api.get>('/TimeReports/GetTimeReport', { params: { id } })).data.Data; -export const newTimeReport = async (deploymentId: string, reportDate: string) => (await api.post('/TimeReports/NewTimeReport', { DeploymentId: deploymentId, ReportDate: reportDate })).data; -// The whole entry list is the unit of save: the server replaces the report's entries with what it is sent. -export const saveTimeEntries = async (timeReportId: string, entries: TimeEntry[]) => (await api.post('/TimeReports/SaveTimeEntries', { TimeReportId: timeReportId, Entries: entries })).data; +export const newTimeReport = async (deploymentId: string, reportDate: string, scope: TimeReportScopeInput = {}) => + ( + await api.post('/TimeReports/NewTimeReport', { + DeploymentId: deploymentId, + ReportDate: reportDate, + DeploymentUnitId: scope.deploymentUnitId ?? null, + DeploymentPersonnelId: scope.deploymentPersonnelId ?? null, + }) + ).data; +// The server replaces the entries of the subjects the caller may write with what it is sent; everyone else's stay as stored. +// Only the local wall clock travels (StartLocal/EndLocal): the server converts it in the department's zone. +export const saveTimeEntries = async (timeReportId: string, entries: TimeEntry[]) => + ( + await api.post('/TimeReports/SaveTimeEntries', { + TimeReportId: timeReportId, + Entries: entries.map(({ StartTime: _start, EndTime: _end, Hours: _hours, ...entry }) => entry), + }) + ).data; export const submitTimeReport = async (id: string) => (await api.post('/TimeReports/SubmitTimeReport', { Id: id })).data; +/** The crew boss signs as the acting user; the customer (agency) signer is a typed name. */ +export const signTimeReport = async (id: string, contractorSigned: boolean, customerSignerName?: string | null) => + (await api.post('/TimeReports/SignTimeReport', { Id: id, ContractorSigned: contractorSigned, CustomerSignerName: customerSignerName || null })).data; +export const approveTimeReport = async (id: string) => (await api.post('/TimeReports/ApproveTimeReport', { Id: id })).data; + +export const getExpenses = async (deploymentId: string) => (await api.get>('/TimeReports/GetExpenses', { params: { deploymentId } })).data.Data; +// Receipts ride as base64; allow a slower link than the default request timeout. +export const saveExpense = async (input: ExpenseInput) => (await api.post>('/TimeReports/SaveExpense', input, { timeout: 60000 })).data.Data; +export const deleteExpense = async (id: string) => (await api.delete>('/TimeReports/DeleteExpense', { params: { id } })).data; export const getFieldCostAccess = async () => (await api.get>('/FieldCost/GetAccess')).data.Data; export const getResourceUsage = async (deploymentId: string) => (await api.get>('/FieldCost/GetResourceUsage', { params: { deploymentId } })).data.Data; diff --git a/src/app/(app)/home.tsx b/src/app/(app)/home.tsx index acc777b1..2292fac1 100644 --- a/src/app/(app)/home.tsx +++ b/src/app/(app)/home.tsx @@ -28,6 +28,7 @@ import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { HStack } from '@/components/ui/hstack'; import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; +import { useSetStatusForCall } from '@/hooks/use-set-status-for-call'; import { logger } from '@/lib/logging'; import { isCallActive, isCallPending, isCallScheduled } from '@/lib/utils'; import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; @@ -462,28 +463,16 @@ export default function DispatchConsole() { }); }; - // Handle setting unit status for a call - const handleSetUnitStatusForCall = (unitId: string, unitName: string) => { - // This could open a status selection modal or navigate to unit status screen - addActivityLogEntry({ - type: 'unit', - action: t('dispatch.unit_status_change'), - description: unitName, - metadata: { unitId, callId: selectedCallId ?? undefined }, - }); - // For now, we'll log the event - in a full implementation, this would open a status picker - }; - - // Handle setting personnel status for a call - const handleSetPersonnelStatusForCall = (personnelId: string, personnelName: string) => { - addActivityLogEntry({ - type: 'personnel', - action: t('dispatch.personnel_status_change'), - description: personnelName, - metadata: { personnelId, callId: selectedCallId ?? undefined }, - }); - // For now, we'll log the event - in a full implementation, this would open a status picker - }; + // "+" set-status-for-call: open the unit/personnel actions panel with the destination preset to the + // selected call; the status is saved through the panel's normal submit path. + const { handleSetUnitStatusForCall, handleSetPersonnelStatusForCall } = useSetStatusForCall({ + calls, + units, + personnel, + selectedCallId, + setSelectedUnitData, + setSelectedPersonnelData, + }); // Get selected call for filter banner const selectedCall = useMemo(() => { diff --git a/src/app/(app)/home.web.tsx b/src/app/(app)/home.web.tsx index d99eae1f..1ffc1201 100644 --- a/src/app/(app)/home.web.tsx +++ b/src/app/(app)/home.web.tsx @@ -28,6 +28,7 @@ import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { HStack } from '@/components/ui/hstack'; import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; +import { useSetStatusForCall } from '@/hooks/use-set-status-for-call'; import { logger } from '@/lib/logging'; import { isCallActive, isCallPending, isCallScheduled } from '@/lib/utils'; import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; @@ -547,39 +548,16 @@ export default function DispatchConsoleWeb() { } }, [selectedCallId]); - // Handle setting unit status for call - const handleSetUnitStatusForCall = useCallback( - (unitId: string) => { - const unit = units.find((u) => u.UnitId === unitId); - if (unit) { - addActivityLogEntry({ - type: 'unit', - action: t('dispatch.unit_status_change'), - description: `${unit.Name}`, - metadata: { unitId, callId: selectedCallId ?? undefined }, - }); - // TODO: Implement status change modal or action - } - }, - [units, addActivityLogEntry, t, selectedCallId] - ); - - // Handle setting personnel status for call - const handleSetPersonnelStatusForCall = useCallback( - (personnelId: string) => { - const person = personnel.find((p) => p.UserId === personnelId); - if (person) { - addActivityLogEntry({ - type: 'personnel', - action: t('dispatch.personnel_status_change'), - description: `${person.FirstName} ${person.LastName}`, - metadata: { personnelId, callId: selectedCallId ?? undefined }, - }); - // TODO: Implement status change modal or action - } - }, - [personnel, addActivityLogEntry, t, selectedCallId] - ); + // "+" set-status-for-call: open the unit/personnel actions panel with the destination preset to the + // selected call; the status is saved through the panel's normal submit path. + const { handleSetUnitStatusForCall, handleSetPersonnelStatusForCall } = useSetStatusForCall({ + calls, + units, + personnel, + selectedCallId, + setSelectedUnitData, + setSelectedPersonnelData, + }); const handleWeatherAlertsPress = useCallback(() => { router.push('/(app)/weather-alerts' as Href); diff --git a/src/app/(app)/operations/[id].tsx b/src/app/(app)/operations/[id].tsx index e072d75b..2cc98d9c 100644 --- a/src/app/(app)/operations/[id].tsx +++ b/src/app/(app)/operations/[id].tsx @@ -4,7 +4,9 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView } from 'react-native'; +import { ExpensesPanel } from '@/components/operations/expenses-panel'; import { MarsPanel } from '@/components/operations/mars-panel'; +import { AwaitingApproval, DayReports, ScopePicker, useScopeLabel } from '@/components/operations/scope-picker'; import { TimeReportEditor } from '@/components/operations/time-report-editor'; import { UsageForm } from '@/components/operations/usage-form'; import { Button, ButtonText } from '@/components/ui/button'; @@ -14,31 +16,46 @@ import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { operationsCapabilities } from '@/lib/operations/capabilities'; -import { localDateKey, rosterSubjects } from '@/lib/operations/time'; -import { DeploymentFinanceMode } from '@/models/v4/operations'; +import { + availableScopes, + canWriteSubject, + coveringReport, + dateOf, + defaultScope, + localDateKey, + reportScope, + reportsOnDate, + scopeKey, + scopeName, + scopeSubjects, + shiftDateKey, + subjectNames, + type TimeScope, +} from '@/lib/operations/time'; +import { DeploymentFinanceMode, TimeReportStatus } from '@/models/v4/operations'; import useAuthStore from '@/stores/auth/store'; import { useDeploymentsStatus } from '@/stores/feature-flags/store'; import { useOperationsStore } from '@/stores/operations/store'; -type Section = 'time' | 'usage' | 'mars'; +type Section = 'time' | 'expenses' | 'usage' | 'mars'; -const shiftDay = (dateKey: string, days: number) => { - const [y, m, d] = dateKey.split('-').map(Number); - return localDateKey(new Date(y, m - 1, d + days)); -}; - -// One deployment: the daily time report for a chosen day, resource usage readings and the MARS F-42 -// state. What the person may edit follows the server's access answer and this app's capabilities. +// One deployment: the crew or individual time report for a chosen day, expenses, resource usage readings and +// the MARS F-42 state. What the person may open and write comes from the server's TimeAccess (their roster row, +// the deployed units they crew) and this app's capabilities; approvers get the submitted queue. export default function OperationsDeploymentScreen() { const { t } = useTranslation(); const router = useRouter(); const { id } = useLocalSearchParams<{ id: string }>(); const flagStatus = useDeploymentsStatus(); const userId = useAuthStore((state) => state.userId); - const { access, costAccess, marsAccess, deployment, report, entries, dirty, issues, warnings, usage, marsItems, validation, busy, error } = useOperationsStore(); + const { costAccess, marsAccess, deployment, reports, scope, report, entries, dirty, issues, warnings, expenses, usage, marsItems, validation, busy, error } = useOperationsStore(); const [dateKey, setDateKey] = useState(localDateKey()); const dateRef = useRef(dateKey); const [section, setSection] = useState
('time'); + const activeUnitId = operationsCapabilities.useActiveUnitId(); + const activeUnitRef = useRef(activeUnitId); + activeUnitRef.current = activeUnitId; + const scopeLabel = useScopeLabel(); useFocusEffect( useCallback(() => { @@ -48,32 +65,57 @@ export default function OperationsDeploymentScreen() { if (!store.access) await store.loadAccess(); if (!useOperationsStore.getState().access?.Enabled) return; await useOperationsStore.getState().open(id); - await useOperationsStore.getState().openReport(dateRef.current, false); - await Promise.all([useOperationsStore.getState().loadUsage(), useOperationsStore.getState().loadMars()]); + const opened = useOperationsStore.getState().deployment; + if (opened) useOperationsStore.getState().setScope(defaultScope(opened, activeUnitRef.current), dateRef.current); + await Promise.all([useOperationsStore.getState().loadExpenses(), useOperationsStore.getState().loadUsage(), useOperationsStore.getState().loadMars()]); })(); return () => useOperationsStore.getState().close(); }, [flagStatus, id]) ); - const activeUnitId = operationsCapabilities.useActiveUnitId(); - const subjects = useMemo(() => (deployment ? rosterSubjects(deployment, { userId: userId ?? null, activeUnitId, manager: !!access?.CanManage }) : []), [deployment, userId, activeUnitId, access?.CanManage]); - const canEditTime = operationsCapabilities.editTime && !!access?.Enabled && subjects.length > 0; + const access = deployment?.TimeAccess ?? null; + const scopes = useMemo(() => (deployment ? availableScopes(deployment) : []), [deployment]); + const names = useMemo(() => (deployment ? subjectNames(deployment) : {}), [deployment]); + // The picker offers the subjects of the open report's own scope (a manager may open any report on the day). + const openScope: TimeScope | null = report ? reportScope(report) : scope; + const subjects = useMemo(() => (deployment ? scopeSubjects(deployment, openScope) : []), [deployment, openScope]); + const dayReports = useMemo(() => reportsOnDate(reports, dateKey), [reports, dateKey]); + const awaiting = useMemo(() => reports.filter((candidate) => candidate.Status === TimeReportStatus.Submitted).sort((a, b) => a.ReportDate.localeCompare(b.ReportDate)), [reports]); + const canApprove = operationsCapabilities.approveTime && !!access?.CanApprove; + const supervises = !!access?.CanManage || canApprove; + const covered = useMemo(() => { + if (report || !scope) return null; + const subject = scope.kind === 'crew' ? scope.unitId : scope.kind === 'individual' ? scope.personnelId : null; + return subject ? coveringReport(reports, dateKey, subject) : null; + }, [report, scope, reports, dateKey]); + const canCreate = operationsCapabilities.editTime && !!scope && (scope.kind !== 'deployment' || !!access?.CanManage); + const writable = useCallback((subjectId: string) => !!deployment && canWriteSubject(deployment, subjectId), [deployment]); const showUsage = operationsCapabilities.recordUsage && !!costAccess?.Enabled && !!costAccess.CanRecordUsage; + const showExpenses = operationsCapabilities.recordExpenses && (access?.CanManage || (access?.WritableSubjectIds.length ?? 0) > 0); const showMars = !!marsAccess?.Enabled && deployment?.FinanceMode === DeploymentFinanceMode.CostRecovery; - const canDraftF42 = operationsCapabilities.draftF42 && showMars && (!!marsAccess?.CanManage || subjects.length > 0); + const canDraftF42 = operationsCapabilities.draftF42 && showMars && (!!marsAccess?.CanManage || (access?.WritableSubjectIds.length ?? 0) > 0); const usageUnits = useMemo(() => { if (!deployment) return []; const units = deployment.Units.filter((unit) => unit.IsActive); - if (access?.CanManage || !activeUnitId) return units; - const mine = units.filter((unit) => String(unit.UnitId) === activeUnitId); - return mine.length > 0 ? mine : units; - }, [deployment, access?.CanManage, activeUnitId]); + if (access?.CanManage) return units; + const crewed = units.filter((unit) => access?.CrewUnitIds.includes(unit.Id)); + const mine = activeUnitId ? crewed.filter((unit) => String(unit.UnitId) === activeUnitId) : []; + return mine.length > 0 ? mine : crewed; + }, [deployment, access, activeUnitId]); const changeDay = (days: number) => { - const next = shiftDay(dateKey, days); + const next = shiftDateKey(dateKey, days); dateRef.current = next; setDateKey(next); - void useOperationsStore.getState().openReport(next, false); + useOperationsStore.getState().setScope(useOperationsStore.getState().scope, next); + }; + + const openReport = (target: { Id: string; ReportDate: string }) => { + const day = dateOf(target.ReportDate); + dateRef.current = day; + setDateKey(day); + setSection('time'); + useOperationsStore.getState().selectReport(target.Id); }; if (flagStatus === 'disabled') return ; @@ -105,13 +147,27 @@ export default function OperationsDeploymentScreen() { {t('operations.roster', { units: deployment.Units.filter((unit) => unit.IsActive).length, personnel: deployment.Personnel.filter((person) => person.IsActive).length })} + {access && access.CrewUnitIds.length > 0 ? ( + + {t('operations.crewOf', { + names: access.CrewUnitIds.map((unitId) => scopeName(deployment, { kind: 'crew', unitId })) + .filter(Boolean) + .join(', '), + })} + + ) : null} ) : null} {deployment ? ( - + + {showExpenses ? ( + + ) : null} {showUsage ? ( ) : null} - + ) : null} - {deployment && (section === 'time' || section === 'usage') ? ( + {deployment && section !== 'mars' ? ( changeDay(-1)} testID="operations-day-previous" accessibilityRole="button" accessibilityLabel={t('operations.previousDay')}> @@ -136,20 +192,51 @@ export default function OperationsDeploymentScreen() { ) : null} {deployment && section === 'time' ? ( - + {canApprove ? : null} + useOperationsStore.getState().setScope(next, dateKey)} /> + {supervises ? : null} + {scopes.length === 0 && !supervises ? {t('operations.time.notOnRoster')} : null} + {scope || report ? ( + void useOperationsStore.getState().openReport(dateKey, true)} + onChange={(next) => useOperationsStore.getState().setEntries(next)} + onSave={() => void useOperationsStore.getState().save()} + onSubmit={() => void useOperationsStore.getState().submit()} + onSign={(crewBoss, customer) => void useOperationsStore.getState().sign(crewBoss, customer)} + onApprove={() => report && void useOperationsStore.getState().approve(report.Id)} + /> + ) : null} + + ) : null} + {deployment && section === 'expenses' && showExpenses ? ( + void useOperationsStore.getState().openReport(dateKey, true)} - onChange={(next) => useOperationsStore.getState().setEntries(next)} - onSave={() => void useOperationsStore.getState().save()} - onSubmit={() => void useOperationsStore.getState().submit()} + onAdd={(input) => useOperationsStore.getState().addExpense(input)} + onRemove={(expenseId) => useOperationsStore.getState().removeExpense(expenseId)} /> ) : null} {deployment && section === 'usage' && showUsage ? ( diff --git a/src/app/(app)/records.tsx b/src/app/(app)/records.tsx index 33e9575b..c80a919e 100644 --- a/src/app/(app)/records.tsx +++ b/src/app/(app)/records.tsx @@ -91,7 +91,7 @@ export default function RecordsScreen() { // The flag is authoritative and resolves fail-closed; an unknown state waits rather than redirecting. if (flagStatus === 'disabled') { - return ; + return ; } return ( diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index 59d5ebc1..89a753b7 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -24,6 +24,7 @@ import { useTranslation } from 'react-i18next'; import { Alert, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; import WebView from 'react-native-webview'; +import { ActivityLinkLegend, ActivityLinkMarker } from '@/components/calls/activity-link-marker'; import { CallSiteInfoTabPanel } from '@/components/calls/call-site-info-tab-panel'; import { VideoFeedsTab } from '@/components/callVideoFeeds/video-feeds-tab'; import { CheckInTab } from '@/components/checkIn/check-in-tab'; @@ -602,9 +603,12 @@ export default function CallDetail() { {callExtraData.Activity.map((event, index) => ( - - {event.StatusText} - + + + {event.StatusText} + + + {event.Name} - {event.Group} @@ -612,6 +616,7 @@ export default function CallDetail() { {event.Note} ))} + ) : ( {t('call_detail.no_timeline')} diff --git a/src/app/call/[id].web.tsx b/src/app/call/[id].web.tsx index 1f96cb9d..dd337145 100644 --- a/src/app/call/[id].web.tsx +++ b/src/app/call/[id].web.tsx @@ -26,6 +26,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; +import { ActivityLinkLegend, ActivityLinkMarker } from '@/components/calls/activity-link-marker'; import { CallSiteInfoTabPanel } from '@/components/calls/call-site-info-tab-panel'; import { VideoFeedsTab } from '@/components/callVideoFeeds/video-feeds-tab'; import { CheckInTab } from '@/components/checkIn/check-in-tab'; @@ -419,7 +420,10 @@ export default function CallDetailWeb() { - {event.StatusText} + + {event.StatusText} + + {event.Name} - {event.Group} @@ -428,6 +432,7 @@ export default function CallDetailWeb() { ))} + ) : ( {t('call_detail.no_timeline')} @@ -1107,10 +1112,16 @@ const styles = StyleSheet.create({ timelineContent: { flex: 1, }, + timelineStatusRow: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: 8, + marginBottom: 2, + }, timelineStatus: { fontSize: 15, fontWeight: '600', - marginBottom: 2, }, timelineInfo: { fontSize: 14, diff --git a/src/components/calls/__tests__/activity-link-marker.test.tsx b/src/components/calls/__tests__/activity-link-marker.test.tsx new file mode 100644 index 00000000..8b86ed8a --- /dev/null +++ b/src/components/calls/__tests__/activity-link-marker.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { type DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData'; + +import { ActivityLinkLegend, ActivityLinkMarker } from '../activity-link-marker'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe('ActivityLinkMarker', () => { + it.each([2, 3, 4])('renders the auto-linked marker with its explanation for source %p', (source) => { + render(); + + const marker = screen.getByTestId('activity-link-marker-auto'); + expect(screen.getByText('call_detail.activity_link.auto_linked')).toBeTruthy(); + expect(marker.props.accessibilityLabel).toBe('call_detail.activity_link.auto_linked'); + expect(marker.props.accessibilityHint).toBe('call_detail.activity_link.auto_linked_hint'); + expect(screen.queryByTestId('activity-link-marker-inferred')).toBeNull(); + }); + + it('renders the inferred marker with its explanation for source 5', () => { + render(); + + const marker = screen.getByTestId('activity-link-marker-inferred'); + expect(screen.getByText('call_detail.activity_link.inferred')).toBeTruthy(); + expect(marker.props.accessibilityHint).toBe('call_detail.activity_link.inferred_hint'); + expect(screen.queryByTestId('activity-link-marker-auto')).toBeNull(); + }); + + it.each([[1], [null], [undefined]])('renders nothing for source %p', (source) => { + const { toJSON } = render(); + expect(toJSON()).toBeNull(); + }); +}); + +describe('ActivityLinkLegend', () => { + const entry = (DestinationSource?: number | null) => ({ DestinationSource }) as DispatchedEventResultData; + + it('renders nothing when no entry is marked', () => { + const { toJSON } = render(); + expect(toJSON()).toBeNull(); + }); + + it('explains only the marker kinds present', () => { + render(); + + expect(screen.getByTestId('activity-link-legend')).toBeTruthy(); + expect(screen.getByText('call_detail.activity_link.auto_linked_hint')).toBeTruthy(); + expect(screen.queryByText('call_detail.activity_link.inferred_hint')).toBeNull(); + }); + + it('explains both kinds when both are present', () => { + render(); + + expect(screen.getByText('call_detail.activity_link.auto_linked_hint')).toBeTruthy(); + expect(screen.getByText('call_detail.activity_link.inferred_hint')).toBeTruthy(); + }); +}); diff --git a/src/components/calls/activity-link-marker.tsx b/src/components/calls/activity-link-marker.tsx new file mode 100644 index 00000000..c221f6fa --- /dev/null +++ b/src/components/calls/activity-link-marker.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Platform } from 'react-native'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { type ActivityLinkKind, getActivityLinkKind, getActivityLinkKinds } from '@/lib/activity-link'; +import { type DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData'; + +const MARKER_TEXT: Record = { + auto: { label: 'call_detail.activity_link.auto_linked', hint: 'call_detail.activity_link.auto_linked_hint', action: 'info' }, + inferred: { label: 'call_detail.activity_link.inferred', hint: 'call_detail.activity_link.inferred_hint', action: 'warning' }, +}; + +const MarkerBadge: React.FC<{ kind: ActivityLinkKind; decorative?: boolean }> = ({ kind, decorative = false }) => { + const { t } = useTranslation(); + const { label, hint, action } = MARKER_TEXT[kind]; + const labelText = t(label); + const hintText = t(hint); + + if (decorative) { + return ( + + {labelText} + + ); + } + + return ( + + {labelText} + + ); +}; + +/** + * Marks a call-activity status entry the server linked on the sender's behalf ("auto-linked") or attributed to + * the call at read time ("inferred"). Renders nothing for explicit entries, older rows and non-status entries. + */ +export const ActivityLinkMarker: React.FC<{ source?: number | null }> = React.memo(({ source }) => { + const kind = getActivityLinkKind(source); + return kind ? : null; +}); + +ActivityLinkMarker.displayName = 'ActivityLinkMarker'; + +/** One line per marker kind present in the list, explaining it; renders nothing when no entry is marked. */ +export const ActivityLinkLegend: React.FC<{ activity?: DispatchedEventResultData[] | null }> = React.memo(({ activity }) => { + const { t } = useTranslation(); + const kinds = React.useMemo(() => getActivityLinkKinds(activity), [activity]); + if (kinds.length === 0) return null; + + return ( + + {kinds.map((kind) => { + const labelText = t(MARKER_TEXT[kind].label); + const hintText = t(MARKER_TEXT[kind].hint); + return ( + + + {hintText} + + ); + })} + + ); +}); + +ActivityLinkLegend.displayName = 'ActivityLinkLegend'; diff --git a/src/components/contacts/__tests__/contact-details-sheet.test.tsx b/src/components/contacts/__tests__/contact-details-sheet.test.tsx index 2a699b55..0f1a7ae2 100644 --- a/src/components/contacts/__tests__/contact-details-sheet.test.tsx +++ b/src/components/contacts/__tests__/contact-details-sheet.test.tsx @@ -1,3 +1,4 @@ +jest.mock('../contact-details-extra', () => ({ ContactDetailsExtra: () => null })); import { render, fireEvent } from '@testing-library/react-native'; import React from 'react'; diff --git a/src/components/contacts/contact-details-extra.tsx b/src/components/contacts/contact-details-extra.tsx new file mode 100644 index 00000000..1b897d50 --- /dev/null +++ b/src/components/contacts/contact-details-extra.tsx @@ -0,0 +1,120 @@ +import { LockIcon } from 'lucide-react-native'; +import React, { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Linking, View } from 'react-native'; + +import { ProtectedRevealBar } from '@/components/data-protection/protected-reveal-bar'; +import { HStack } from '@/components/ui/hstack'; +import { HomeIcon, MapPinIcon, SettingsIcon } from '@/components/ui/lucide-icons'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { mapsUrls } from '@/lib/contacts/format'; +import { logger } from '@/lib/logging'; +import { type ContactAddressData, type ContactResultData } from '@/models/v4/contacts/contactResultData'; +import { useContactsStore } from '@/stores/contacts/store'; + +interface ContactDetailsExtraProps { + contact: ContactResultData & { WithheldFields?: string[] }; + /** Dispatch renders custom fields through its own UDF renderer. */ + showCustomFields?: boolean; +} + +const openMaps = async (query: string) => { + const { app, web } = mapsUrls(query); + try { + if (app && (await Linking.canOpenURL(app))) { + await Linking.openURL(app); + return; + } + await Linking.openURL(web); + } catch (error) { + logger.warn({ message: 'Could not open maps for contact address', context: { error } }); + } +}; + +const AddressRow = ({ label, address }: { label: string; address: ContactAddressData }) => { + const line = address.Formatted || [address.Address1, address.City, address.State, address.PostalCode, address.Country].filter(Boolean).join(', '); + if (!line) return null; + return ( + void openMaps(line)} accessibilityRole="link" accessibilityLabel={`${label}, ${line}`} testID="contact-address"> + + + + + + {label} + {line} + + + + ); +}; + +// What the slim contact list never carried, for every field app's detail sheet: the resolved physical and +// mailing addresses (tap to open in maps), the category, the department's mobile-visible custom fields, and +// — for a protected department — which details were withheld with the reveal that re-reads them. +export const ContactDetailsExtra: React.FC = ({ contact, showCustomFields = true }) => { + const { t } = useTranslation(); + const contactId = contact.ContactId; + + // Opening a contact reads its notes fresh: a cached copy may predate an unlock and still be redacted. + useEffect(() => { + void useContactsStore.getState().fetchContactNotes(contactId, true); + }, [contactId]); + + const refresh = useCallback(async () => { + const store = useContactsStore.getState(); + await Promise.all([store.fetchContactDetails(contactId), store.fetchContactNotes(contactId, true)]); + }, [contactId]); + + const withheld = contact.WithheldFields ?? []; + const category = contact.CategoryName || contact.Category?.Name; + const customFields = showCustomFields ? (contact.CustomFields ?? []) : []; + + return ( + + {contact.IsProtected || withheld.length > 0 ? ( + + {withheld.length > 0 ? ( + + + {t('contacts.withheld', { count: withheld.length })} + + ) : null} + + + ) : null} + {category ? ( + + + {t('contacts.categoryLabel', { name: category })} + + ) : null} + {contact.PhysicalAddress ? : null} + {contact.MailingAddress ? : null} + {contact.LocationGeofence ? ( + + + + + {t('contacts.hasGeofence')} + + ) : null} + {customFields.length > 0 ? ( + + + + {t('contacts.customFields')} + + {customFields.map((field) => ( + + {field.GroupName ? `${field.GroupName} · ${field.Label}` : field.Label} + {field.Value} + + ))} + + ) : null} + + ); +}; diff --git a/src/components/contacts/contact-details-sheet.tsx b/src/components/contacts/contact-details-sheet.tsx index 4febaeac..e98aa67a 100644 --- a/src/components/contacts/contact-details-sheet.tsx +++ b/src/components/contacts/contact-details-sheet.tsx @@ -23,6 +23,7 @@ import { X, } from '@/components/ui/lucide-icons'; import { useAnalytics } from '@/hooks/use-analytics'; +import { htmlToText, withoutRedacted } from '@/lib/contacts/format'; import { ContactType } from '@/models/v4/contacts/contactResultData'; import { useContactsStore } from '@/stores/contacts/store'; @@ -33,6 +34,7 @@ import { HStack } from '../ui/hstack'; import { Pressable } from '../ui/pressable'; import { Text } from '../ui/text'; import { VStack } from '../ui/vstack'; +import { ContactDetailsExtra } from './contact-details-extra'; import { ContactFilesPanel } from './contact-files-panel'; import { ContactNotesList } from './contact-notes-list'; import { ContactPreplanPanel } from './contact-preplan-panel'; @@ -91,13 +93,17 @@ export const ContactDetailsSheet: React.FC = () => { const { trackEvent } = useAnalytics(); const { width, height } = useWindowDimensions(); const isLandscape = width > height; - const { contacts, selectedContactId, isDetailsOpen, closeDetails } = useContactsStore(); + const { contacts, selectedContactId, selectedContactDetails, isDetailsOpen, closeDetails } = useContactsStore(); const [activeTab, setActiveTab] = useState<'details' | 'notes' | 'preplan' | 'files'>('details'); const selectedContact = React.useMemo(() => { if (!selectedContactId) return null; - return contacts.find((contact) => contact.ContactId === selectedContactId); - }, [contacts, selectedContactId]); + // Prefer the full GetContactById record — the list payload is slim (no address, custom fields or + // category) — and fall back to the list row while it loads. Withheld values are removed so the + // sheet never shows the REDACTED sentinel as data or offers to dial it. + const record = selectedContactDetails?.ContactId === selectedContactId ? selectedContactDetails : contacts.find((contact) => contact.ContactId === selectedContactId); + return record ? withoutRedacted(record) : null; + }, [contacts, selectedContactId, selectedContactDetails]); // Track when contact details sheet is opened/rendered React.useEffect(() => { @@ -203,7 +209,9 @@ export const ContactDetailsSheet: React.FC = () => { {selectedContact.ContactType === ContactType.Person ? t('contacts.person') : t('contacts.company')} {selectedContact.OtherName ? ({selectedContact.OtherName}) : null} - {selectedContact.Category?.Name ? {selectedContact.Category.Name} : null} + {selectedContact.CategoryName || selectedContact.Category?.Name ? ( + {selectedContact.CategoryName || selectedContact.Category?.Name} + ) : null} @@ -235,6 +243,7 @@ export const ContactDetailsSheet: React.FC = () => { {activeTab === 'details' ? ( + {/* Contact Information Section */} {hasContactInfo ? (
}> @@ -302,9 +311,9 @@ export const ContactDetailsSheet: React.FC = () => { {hasAdditionalInfo ? (
} defaultExpanded={false}> - - - + + +
) : null} diff --git a/src/components/contacts/contact-files-list.tsx b/src/components/contacts/contact-files-list.tsx index 49aab520..d688b95e 100644 --- a/src/components/contacts/contact-files-list.tsx +++ b/src/components/contacts/contact-files-list.tsx @@ -3,7 +3,7 @@ import * as Sharing from 'expo-sharing'; import { DownloadIcon, FileIcon, LockIcon, PaperclipIcon } from 'lucide-react-native'; import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Alert } from 'react-native'; +import { Alert, Platform } from 'react-native'; import { getContactFileBase64 } from '@/api/contacts/contactFiles'; import { ProtectedText } from '@/components/data-protection/protected-text'; @@ -73,6 +73,28 @@ export const ContactFilesList: React.FC = ({ files, isLoa const base64 = await getContactFileBase64(file); const fallbackName = `contact_file_${file.Id}`; const fileName = safeFileName(isFieldRedacted(file.RedactedFields, FileFieldIds.fileName, file.FileName) ? null : file.FileName, fallbackName); + + if (Platform.OS === 'web') { + // The dispatch console runs in a browser: there is no document directory or share sheet, so the + // bytes become a browser download. The object URL is always revoked, even when the click throws. + let objectUrl: string | null = null; + let link: HTMLAnchorElement | null = null; + try { + const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); + objectUrl = URL.createObjectURL(new Blob([bytes], { type: file.Mime || 'application/octet-stream' })); + link = document.createElement('a'); + link.href = objectUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + } finally { + link?.remove(); + if (objectUrl) URL.revokeObjectURL(objectUrl); + } + trackEvent('contact_file_download_completed', { contextId: contextId ?? '', fileId: file.Id, wasShared: false }); + return; + } + const fileUri = `${FileSystem.documentDirectory}${fileName}`; await FileSystem.writeAsStringAsync(fileUri, base64, { encoding: FileSystem.EncodingType.Base64 }); diff --git a/src/components/dispatch-console/__tests__/activity-log-panel-actions.test.tsx b/src/components/dispatch-console/__tests__/activity-log-panel-actions.test.tsx new file mode 100644 index 00000000..db3b96f5 --- /dev/null +++ b/src/components/dispatch-console/__tests__/activity-log-panel-actions.test.tsx @@ -0,0 +1,114 @@ +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; +import { useDashboardViewStore } from '@/stores/dispatch/dashboard-view-store'; +import { useUnitActionsStore } from '@/stores/dispatch/unit-actions-store'; + +import { ActivityLogPanel } from '../activity-log-panel'; + +jest.mock('expo-router', () => ({ router: { push: jest.fn() } })); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('@/stores/dispatch/dashboard-view-store', () => { + const { create } = require('zustand'); + const useDashboardViewStore = create((set: any) => ({ + collapsedCards: {}, + setCardCollapsed: (card: string, collapsed: boolean) => set((state: any) => ({ collapsedCards: { ...state.collapsedCards, [card]: collapsed } })), + })); + return { useDashboardViewStore, selectCardCollapsed: (card: string) => (state: any) => state.collapsedCards[card] ?? false }; +}); + +jest.mock('@/stores/checkIn/store', () => ({ + useCheckInStore: (selector: (state: unknown) => unknown) => selector({ timerStatuses: [], isLoadingStatuses: false }), +})); + +jest.mock('@/stores/weatherAlerts/store', () => ({ + useWeatherAlertsStore: (selector: (state: unknown) => unknown) => selector({ alerts: [], settings: null }), +})); + +jest.mock('@/hooks/use-check-in-timer-polling', () => ({ useCheckInTimerPolling: jest.fn() })); +jest.mock('@/components/checkIn/check-in-bottom-sheet', () => ({ CheckInBottomSheet: () => null })); +jest.mock('@/components/checkIn/check-in-timer-card', () => ({ CheckInTimerCard: () => null })); +jest.mock('@/components/ui/icon', () => ({ Icon: () => null })); + +jest.mock('../panel-header', () => ({ + PanelHeader: ({ title }: { title: string }) => { + const { Text } = require('react-native'); + return {title}; + }, +})); + +// The actions panels themselves are covered by their own tests; here only which one is shown matters +jest.mock('../unit-actions-panel', () => ({ + UnitActionsPanel: ({ unit }: { unit: UnitInfoResultData }) => { + const { Text } = require('react-native'); + return {`unit-actions:${unit.UnitId}`}; + }, +})); + +jest.mock('../personnel-actions-panel', () => ({ + PersonnelActionsPanel: () => null, +})); + +const call = { CallId: '777', Number: '26-777', Name: 'MVA', State: 0 } as CallResultData; +const engine = { UnitId: 'e1', Name: 'Engine 1' } as UnitInfoResultData; + +const baseProps = { entries: [], isLoading: false, isCallFilterActive: true, selectedCallId: '777' }; + +describe('ActivityLogPanel actions for "+" set-status-for-call', () => { + beforeEach(() => { + useUnitActionsStore.getState().reset(); + useDashboardViewStore.setState({ collapsedCards: {} } as any); + }); + + it('keeps the call context when the unit selection arrives after "+" opened the panel', () => { + const view = render(); + + // What the "+" handler does: open the store with the call context, then select the unit + act(() => { + useUnitActionsStore.getState().openActions(engine, { callContext: call }); + }); + view.rerender(); + + expect(useUnitActionsStore.getState().callContext).toBe(call); + expect(useUnitActionsStore.getState().statusSelectedCall).toBe(call); + expect(screen.getByText('unit-actions:e1')).toBeTruthy(); + }); + + it('surfaces the actions tab (and expands the card) when "+" re-targets the already-selected unit', () => { + const view = render(); + expect(screen.getByText('unit-actions:e1')).toBeTruthy(); + const session = useUnitActionsStore.getState().actionsSessionId; + + // Dispatcher goes back to the activity tab, then presses "+" on the same unit + fireEvent.press(screen.getByText('dispatch.activity')); + expect(screen.queryByText('unit-actions:e1')).toBeNull(); + act(() => { + useDashboardViewStore.getState().setCardCollapsed('activity-log', true); + }); + act(() => { + useUnitActionsStore.getState().openActions(engine, { callContext: call }); + }); + view.rerender(); + + expect(useUnitActionsStore.getState().actionsSessionId).toBe(session + 1); + expect(useDashboardViewStore.getState().collapsedCards['activity-log']).toBe(false); + expect(screen.getByText('unit-actions:e1')).toBeTruthy(); + }); + + it('still opens the unit actions (without a call context) on a plain unit selection', () => { + const view = render(); + view.rerender(); + + const state = useUnitActionsStore.getState(); + expect(state.isActionsOpen).toBe(true); + expect(state.selectedUnit).toBe(engine); + expect(state.callContext).toBeNull(); + expect(screen.getByText('unit-actions:e1')).toBeTruthy(); + }); +}); diff --git a/src/components/dispatch-console/__tests__/activity-log-panel-link-markers.test.tsx b/src/components/dispatch-console/__tests__/activity-log-panel-link-markers.test.tsx new file mode 100644 index 00000000..38115a75 --- /dev/null +++ b/src/components/dispatch-console/__tests__/activity-log-panel-link-markers.test.tsx @@ -0,0 +1,89 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { type DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData'; + +import { ActivityLogPanel } from '../activity-log-panel'; + +jest.mock('expo-router', () => ({ router: { push: jest.fn() } })); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('@/stores/dispatch/dashboard-view-store', () => ({ + useDashboardViewStore: (selector: (state: unknown) => unknown) => selector({ collapsedCards: {}, setCardCollapsed: jest.fn() }), + selectCardCollapsed: () => () => false, +})); + +jest.mock('@/stores/checkIn/store', () => ({ + useCheckInStore: (selector: (state: unknown) => unknown) => selector({ timerStatuses: [], isLoadingStatuses: false }), +})); + +jest.mock('@/stores/weatherAlerts/store', () => ({ + useWeatherAlertsStore: (selector: (state: unknown) => unknown) => selector({ alerts: [], settings: null }), +})); + +jest.mock('@/hooks/use-check-in-timer-polling', () => ({ useCheckInTimerPolling: jest.fn() })); +jest.mock('@/components/checkIn/check-in-bottom-sheet', () => ({ CheckInBottomSheet: () => null })); +jest.mock('@/components/checkIn/check-in-timer-card', () => ({ CheckInTimerCard: () => null })); +jest.mock('@/components/ui/icon', () => ({ Icon: () => null })); +jest.mock('../panel-header', () => ({ PanelHeader: () => null })); +jest.mock('../unit-actions-panel', () => ({ UnitActionsPanel: () => null })); +jest.mock('../personnel-actions-panel', () => ({ PersonnelActionsPanel: () => null })); + +const activity = (Id: string, StatusText: string, DestinationSource?: number | null): DispatchedEventResultData => ({ + Id, + Timestamp: '2026-09-23 10:00', + Type: 'Unit', + Name: `Engine ${Id}`, + GroupId: '1', + Group: 'Station 1', + Note: '', + StatusId: 2, + Location: '', + StatusText, + StatusColor: '#3b82f6', + DestinationSource, +}); + +const renderPanel = (callActivity: DispatchedEventResultData[]) => { + const view = render(); + // Selecting a call jumps the panel to its actions tab; the call's activity lives on the activity tab + fireEvent.press(screen.getByText('dispatch.activity')); + return view; +}; + +describe('ActivityLogPanel call activity link markers', () => { + it.each([2, 3, 4])('marks an entry auto-linked for source %p', (source) => { + renderPanel([activity('1', 'Responding', source)]); + + expect(screen.getByText('Responding')).toBeTruthy(); + expect(screen.getByTestId('activity-link-marker-auto')).toBeTruthy(); + expect(screen.queryByTestId('activity-link-marker-inferred')).toBeNull(); + expect(screen.getByTestId('activity-link-legend')).toBeTruthy(); + }); + + it('marks an entry inferred for source 5', () => { + renderPanel([activity('1', 'On Scene', 5)]); + + expect(screen.getByTestId('activity-link-marker-inferred')).toBeTruthy(); + expect(screen.queryByTestId('activity-link-marker-auto')).toBeNull(); + }); + + it.each([[1], [null], [undefined]])('shows no marker or legend for source %p', (source) => { + renderPanel([activity('1', 'Available', source)]); + + expect(screen.getByText('Available')).toBeTruthy(); + expect(screen.queryByTestId('activity-link-marker-auto')).toBeNull(); + expect(screen.queryByTestId('activity-link-marker-inferred')).toBeNull(); + expect(screen.queryByTestId('activity-link-legend')).toBeNull(); + }); + + it('marks only the entries the server linked or inferred in a mixed list', () => { + renderPanel([activity('1', 'Dispatched', 1), activity('2', 'Responding', 3), activity('3', 'On Scene', 5), activity('4', 'Available', null)]); + + expect(screen.getAllByTestId('activity-link-marker-auto')).toHaveLength(1); + expect(screen.getAllByTestId('activity-link-marker-inferred')).toHaveLength(1); + }); +}); diff --git a/src/components/dispatch-console/__tests__/personnel-actions-panel.test.tsx b/src/components/dispatch-console/__tests__/personnel-actions-panel.test.tsx new file mode 100644 index 00000000..98526dbb --- /dev/null +++ b/src/components/dispatch-console/__tests__/personnel-actions-panel.test.tsx @@ -0,0 +1,153 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { getAllGroups } from '@/api/groups/groups'; +import { getPois } from '@/api/mapping/mapping'; +import { savePersonsStatuses } from '@/api/personnel/personnelStatuses'; +import { getAllPersonnelStaffings, getAllPersonnelStatuses } from '@/api/satuses'; +import { CustomStateDetailType, DestinationEntityType } from '@/lib/destination-helpers'; +import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; +import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; +import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store'; +import { usePersonnelActionsStore } from '@/stores/dispatch/personnel-actions-store'; + +import { PersonnelActionsPanel } from '../personnel-actions-panel'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => (typeof fallback === 'string' ? fallback : key), + }), +})); + +jest.mock('@/api/groups/groups', () => ({ getAllGroups: jest.fn() })); +jest.mock('@/api/mapping/mapping', () => ({ getPois: jest.fn() })); +jest.mock('@/api/satuses', () => ({ getAllPersonnelStatuses: jest.fn(), getAllPersonnelStaffings: jest.fn() })); +jest.mock('@/api/personnel/personnelStatuses', () => ({ savePersonsStatuses: jest.fn() })); +jest.mock('@/api/personnel/personnelStaffing', () => ({ savePersonsStaffings: jest.fn() })); + +jest.mock('@/components/calls/udf-fields-renderer', () => ({ + UdfFieldsRenderer: () => null, +})); + +const mockCalls: CallResultData[] = [ + { CallId: 'A', Number: '26-A', Name: 'Structure Fire', State: 0 } as CallResultData, + { CallId: 'B', Number: '26-B', Name: 'Medical Aid', State: 0 } as CallResultData, +]; +const mockFetchCalls = jest.fn(); +jest.mock('@/stores/calls/store', () => ({ + useCallsStore: (selector: (state: { calls: CallResultData[]; fetchCalls: () => void }) => unknown) => selector({ calls: mockCalls, fetchCalls: mockFetchCalls }), +})); + +jest.mock('@/components/ui/actionsheet', () => { + const { View } = require('react-native'); + return { + Actionsheet: ({ children, isOpen }: any) => (isOpen ? {children} : null), + ActionsheetBackdrop: () => null, + ActionsheetContent: ({ children }: any) => {children}, + ActionsheetDragIndicator: () => null, + ActionsheetDragIndicatorWrapper: ({ children }: any) => children, + }; +}); + +jest.mock('@/components/ui/button', () => { + const { Text, TouchableOpacity } = require('react-native'); + return { + Button: ({ children, onPress, isDisabled }: any) => ( + + {children} + + ), + ButtonText: ({ children }: any) => {children}, + ButtonSpinner: () => null, + }; +}); + +jest.mock('@/components/ui/icon', () => ({ + Icon: () => null, +})); + +jest.mock('@/components/ui/spinner', () => ({ + Spinner: () => null, +})); + +const mockSavePersonsStatuses = savePersonsStatuses as jest.MockedFunction; + +const status = (Id: number, Text: string, Detail: number): StatusesResultData => ({ Id, Type: 1, StateId: 0, Text, BColor: '#123456', Color: '', Gps: false, Note: 0, Detail }) as StatusesResultData; +const statuses = [status(1, 'Not Responding', CustomStateDetailType.None), status(2, 'Responding', CustomStateDetailType.CallsAndStations), status(3, 'On Scene', CustomStateDetailType.Calls)]; + +const person = (UserId: string, StatusDestinationId = ''): PersonnelInfoResultData => ({ UserId, FirstName: 'Pat', LastName: UserId, GroupName: 'Station 1', StatusDestinationId }) as PersonnelInfoResultData; + +const selectedCallInStore = () => usePersonnelActionsStore.getState().statusSelectedCall?.CallId ?? null; + +const pickStatus = async (text: string) => { + fireEvent.press(screen.getByText('dispatch.personnel_actions.select_status')); + fireEvent.press(await screen.findByText(text)); +}; + +describe('PersonnelActionsPanel destination defaults', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getAllPersonnelStatuses as jest.Mock).mockResolvedValue({ Data: statuses }); + (getAllPersonnelStaffings as jest.Mock).mockResolvedValue({ Data: [] }); + (getAllGroups as jest.Mock).mockResolvedValue({ Data: [] }); + (getPois as jest.Mock).mockResolvedValue({ Data: [] }); + mockSavePersonsStatuses.mockResolvedValue({} as any); + usePersonnelActionsStore.getState().reset(); + useDispatchConsoleStore.setState({ selectedCallId: null, isCallFilterActive: false }); + }); + + it("uses the console's selected call and keeps it sticky across submits", async () => { + useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true }); + const target = person('p1'); + act(() => { + usePersonnelActionsStore.getState().openActions(target); + }); + render(); + await waitFor(() => expect(usePersonnelActionsStore.getState().isLoadingOptions).toBe(false)); + await waitFor(() => expect(selectedCallInStore()).toBe('B')); + + await pickStatus('Responding'); + expect(screen.getByText('#26-B - Medical Aid')).toBeTruthy(); + fireEvent.press(screen.getByTestId('update-button')); + await waitFor(() => expect(mockSavePersonsStatuses).toHaveBeenCalledTimes(1)); + expect(mockSavePersonsStatuses.mock.calls[0][0]).toMatchObject({ UserIds: ['p1'], Type: '2', RespondingTo: 'B', RespondingToType: DestinationEntityType.Call }); + + await waitFor(() => expect(usePersonnelActionsStore.getState().selectedStatus).toBeNull()); + await pickStatus('On Scene'); + fireEvent.press(screen.getByTestId('update-button')); + await waitFor(() => expect(mockSavePersonsStatuses).toHaveBeenCalledTimes(2)); + expect(mockSavePersonsStatuses.mock.calls[1][0]).toMatchObject({ Type: '3', RespondingTo: 'B', RespondingToType: DestinationEntityType.Call }); + }); + + it("prefers the person's current destination call and re-initialises for the next person", async () => { + useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true }); + const first = person('p1', 'A'); + const second = person('p2'); + act(() => { + usePersonnelActionsStore.getState().openActions(first); + }); + const view = render(); + await waitFor(() => expect(selectedCallInStore()).toBe('A')); + + view.rerender(); + act(() => { + usePersonnelActionsStore.getState().openActions(second); + }); + await waitFor(() => expect(selectedCallInStore()).toBe('B')); + }); + + it('does not send a leftover call with a status that does not support calls', async () => { + const target = person('p1', 'A'); + act(() => { + usePersonnelActionsStore.getState().openActions(target); + }); + render(); + await waitFor(() => expect(selectedCallInStore()).toBe('A')); + + await pickStatus('Not Responding'); + fireEvent.press(screen.getByTestId('update-button')); + await waitFor(() => expect(mockSavePersonsStatuses).toHaveBeenCalledTimes(1)); + expect(mockSavePersonsStatuses.mock.calls[0][0]).toMatchObject({ Type: '1', RespondingTo: '', RespondingToType: null }); + }); +}); diff --git a/src/components/dispatch-console/__tests__/unit-actions-panel.test.tsx b/src/components/dispatch-console/__tests__/unit-actions-panel.test.tsx new file mode 100644 index 00000000..05cb6fde --- /dev/null +++ b/src/components/dispatch-console/__tests__/unit-actions-panel.test.tsx @@ -0,0 +1,221 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { getSetUnitStatusData } from '@/api/dispatch/dispatch'; +import { saveUnitStatus } from '@/api/units/unitStatuses'; +import { CustomStateDetailType, DestinationEntityType } from '@/lib/destination-helpers'; +import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; +import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; +import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store'; +import { useUnitActionsStore } from '@/stores/dispatch/unit-actions-store'; + +import { UnitActionsPanel } from '../unit-actions-panel'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => (typeof fallback === 'string' ? fallback : key), + }), +})); + +jest.mock('@/api/dispatch/dispatch', () => ({ + getSetUnitStatusData: jest.fn(), +})); + +jest.mock('@/api/units/unitStatuses', () => ({ + saveUnitStatus: jest.fn(), +})); + +jest.mock('@/components/calls/udf-fields-renderer', () => ({ + UdfFieldsRenderer: () => null, +})); + +// Calls store: a stable array so the panel's memoised active-call list does not churn between renders +const mockCalls: CallResultData[] = [ + { CallId: 'A', Number: '26-A', Name: 'Structure Fire', State: 0 } as CallResultData, + { CallId: 'B', Number: '26-B', Name: 'Medical Aid', State: 0 } as CallResultData, + { CallId: 'CLOSED', Number: '26-C', Name: 'Old Call', State: 4 } as CallResultData, +]; +const mockFetchCalls = jest.fn(); +jest.mock('@/stores/calls/store', () => ({ + useCallsStore: (selector: (state: { calls: CallResultData[]; fetchCalls: () => void }) => unknown) => selector({ calls: mockCalls, fetchCalls: mockFetchCalls }), +})); + +jest.mock('@/stores/units/store', () => ({ + useUnitsStore: { + getState: () => ({ unitStatuses: [], fetchUnits: jest.fn() }), + }, +})); + +jest.mock('@/components/ui/actionsheet', () => { + const { View } = require('react-native'); + return { + Actionsheet: ({ children, isOpen }: any) => (isOpen ? {children} : null), + ActionsheetBackdrop: () => null, + ActionsheetContent: ({ children }: any) => {children}, + ActionsheetDragIndicator: () => null, + ActionsheetDragIndicatorWrapper: ({ children }: any) => children, + }; +}); + +jest.mock('@/components/ui/button', () => { + const { Text, TouchableOpacity } = require('react-native'); + return { + Button: ({ children, onPress, isDisabled }: any) => ( + + {children} + + ), + ButtonText: ({ children }: any) => {children}, + ButtonSpinner: () => null, + }; +}); + +jest.mock('@/components/ui/icon', () => ({ + Icon: () => null, +})); + +jest.mock('@/components/ui/spinner', () => ({ + Spinner: () => null, +})); + +const mockGetSetUnitStatusData = getSetUnitStatusData as jest.MockedFunction; +const mockSaveUnitStatus = saveUnitStatus as jest.MockedFunction; + +const status = (Id: number, Text: string, Detail: number): StatusesResultData => ({ Id, Type: 3, StateId: 0, Text, BColor: '#123456', Color: '', Gps: false, Note: 0, Detail }) as StatusesResultData; +const statuses = [status(0, 'Available', CustomStateDetailType.Stations), status(3, 'Responding', CustomStateDetailType.Calls), status(6, 'On Scene', CustomStateDetailType.Calls), status(8, 'Out of Service', CustomStateDetailType.None)]; + +const unit = (UnitId: string, CurrentDestinationId = ''): UnitInfoResultData => ({ UnitId, Name: `Unit ${UnitId}`, Type: 'Engine', CustomStatusSetId: '', CurrentDestinationId }) as UnitInfoResultData; + +const selectedCallInStore = () => useUnitActionsStore.getState().statusSelectedCall?.CallId ?? null; + +const renderOpenPanel = async (target: UnitInfoResultData) => { + act(() => { + useUnitActionsStore.getState().openActions(target); + }); + const view = render(); + await waitFor(() => expect(useUnitActionsStore.getState().isLoadingOptions).toBe(false)); + return view; +}; + +const pickStatus = async (text: string) => { + fireEvent.press(screen.getByText('dispatch.unit_actions_panel.select_status')); + fireEvent.press(await screen.findByText(text)); +}; + +describe('UnitActionsPanel destination defaults', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetSetUnitStatusData.mockResolvedValue({ Data: { Statuses: statuses, Calls: [], Stations: [], DestinationPois: [] } } as any); + mockSaveUnitStatus.mockResolvedValue({} as any); + useUnitActionsStore.getState().reset(); + useDispatchConsoleStore.setState({ selectedCallId: null, isCallFilterActive: false }); + }); + + it("defaults to the unit's current destination when it is an active call", async () => { + useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true }); + await renderOpenPanel(unit('u1', 'A')); + + await waitFor(() => expect(selectedCallInStore()).toBe('A')); + }); + + it("falls back to the console's selected call when the unit's destination is not an active call", async () => { + useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true }); + await renderOpenPanel(unit('u1', 'CLOSED')); + + await waitFor(() => expect(selectedCallInStore()).toBe('B')); + + // The panel shows the defaulted call once a status that accepts calls is picked + await pickStatus('Responding'); + expect(screen.getByText('#26-B - Medical Aid')).toBeTruthy(); + }); + + it('defaults to no destination when neither the unit destination nor a selected call is active', async () => { + await renderOpenPanel(unit('u1', 'CLOSED')); + + await waitFor(() => expect(useUnitActionsStore.getState().destinationInitializedSessionId).toBe(useUnitActionsStore.getState().actionsSessionId)); + expect(useUnitActionsStore.getState().statusDestinationType).toBe('none'); + }); + + it('shows no destination for a status that does not support the defaulted call', async () => { + useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true }); + await renderOpenPanel(unit('u1')); + await waitFor(() => expect(selectedCallInStore()).toBe('B')); + + await pickStatus('Available'); + await waitFor(() => expect(screen.getAllByText('dispatch.unit_actions_panel.no_destination').length).toBeGreaterThan(0)); + expect(screen.queryByText('#26-B - Medical Aid')).toBeNull(); + }); + + it('keeps the destination after a successful submit and does not re-apply the default', async () => { + await renderOpenPanel(unit('u1', 'A')); + await waitFor(() => expect(selectedCallInStore()).toBe('A')); + + await pickStatus('Responding'); + fireEvent.press(screen.getByTestId('update-status-button')); + await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalledTimes(1)); + expect(mockSaveUnitStatus.mock.calls[0][0]).toMatchObject({ Id: 'u1', Type: '3', RespondingTo: 'A', RespondingToType: DestinationEntityType.Call }); + + // Follow-up status for the same unit still goes to call A + await waitFor(() => expect(useUnitActionsStore.getState().selectedStatus).toBeNull()); + expect(selectedCallInStore()).toBe('A'); + await pickStatus('On Scene'); + expect(screen.getByText('#26-A - Structure Fire')).toBeTruthy(); + fireEvent.press(screen.getByTestId('update-status-button')); + await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalledTimes(2)); + expect(mockSaveUnitStatus.mock.calls[1][0]).toMatchObject({ Id: 'u1', Type: '6', RespondingTo: 'A', RespondingToType: DestinationEntityType.Call }); + }); + + it('re-initialises the default destination when the selected unit changes', async () => { + useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true }); + const first = unit('u1', 'A'); + const second = unit('u2'); + const view = await renderOpenPanel(first); + await waitFor(() => expect(selectedCallInStore()).toBe('A')); + + // The dashboard re-renders the panel with the new unit before its parent re-opens the store for it + view.rerender(); + act(() => { + useUnitActionsStore.getState().openActions(second); + }); + + await waitFor(() => expect(selectedCallInStore()).toBe('B')); + expect(useUnitActionsStore.getState().selectedUnit?.UnitId).toBe('u2'); + }); + + it('does not re-apply the default when the panel remounts within the same open session', async () => { + useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true }); + const target = unit('u1', 'A'); + const view = await renderOpenPanel(target); + await waitFor(() => expect(selectedCallInStore()).toBe('A')); + + // The dispatcher changes the destination, then switches tabs away and back (unmount/remount) + act(() => { + useUnitActionsStore.getState().setStatusDestinationType('none'); + }); + view.unmount(); + render(); + await waitFor(() => expect(useUnitActionsStore.getState().isLoadingOptions).toBe(false)); + + expect(useUnitActionsStore.getState().statusDestinationType).toBe('none'); + }); + + it('shows and sends the explicit call context even for a status whose Detail lists no destinations', async () => { + const target = unit('u1', 'A'); + act(() => { + useUnitActionsStore.getState().openActions(target, { callContext: mockCalls[1] }); + }); + render(); + await waitFor(() => expect(useUnitActionsStore.getState().isLoadingOptions).toBe(false)); + + // The explicit context wins over the unit's current destination + expect(selectedCallInStore()).toBe('B'); + + await pickStatus('Out of Service'); + expect(screen.getByText('#26-B - Medical Aid')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('update-status-button')); + await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalledTimes(1)); + expect(mockSaveUnitStatus.mock.calls[0][0]).toMatchObject({ Id: 'u1', Type: '8', RespondingTo: 'B', RespondingToType: DestinationEntityType.Call }); + }); +}); diff --git a/src/components/dispatch-console/activity-log-panel.tsx b/src/components/dispatch-console/activity-log-panel.tsx index 1f8b032b..46e69ac0 100644 --- a/src/components/dispatch-console/activity-log-panel.tsx +++ b/src/components/dispatch-console/activity-log-panel.tsx @@ -24,6 +24,7 @@ import { usePersonnelActionsStore } from '@/stores/dispatch/personnel-actions-st import { useUnitActionsStore } from '@/stores/dispatch/unit-actions-store'; import { useWeatherAlertsStore } from '@/stores/weatherAlerts/store'; +import { ActivityLinkLegend, ActivityLinkMarker } from '../calls/activity-link-marker'; import { CheckInBottomSheet } from '../checkIn/check-in-bottom-sheet'; import { CheckInTimerCard } from '../checkIn/check-in-timer-card'; import { PanelHeader } from './panel-header'; @@ -188,6 +189,7 @@ const CallActivityItem: React.FC<{ activity: DispatchedEventResultData }> = Reac ) : null} + {activity.Note ? ( {activity.Note} @@ -332,6 +334,12 @@ const ActivityLogPanelComponent: React.FC = ({ // Track previous call selection to avoid unnecessary tab switches const prevSelectedCallIdRef = useRef(undefined); + // Actions-panel open sessions — a "+" set-status-for-call open (with a call context) must surface the + // actions tab even when the unit/person was already selected and the selection itself did not change. + const unitActionsSessionId = useUnitActionsStore((s) => s.actionsSessionId); + const personnelActionsSessionId = usePersonnelActionsStore((s) => s.actionsSessionId); + const prevActionsSessionIdsRef = useRef({ unit: unitActionsSessionId, personnel: personnelActionsSessionId }); + // Reset to activity tab if check-ins tab is active but call filter is cleared useEffect(() => { if (!isCallFilterActive && activeTab === 'checkins') { @@ -364,7 +372,11 @@ const ActivityLogPanelComponent: React.FC = ({ prevSelectedPersonnelIdRef.current = currentId; if (selectedPersonnel && currentId) { - openPersonnelActions(selectedPersonnel); + // Don't re-open (and wipe) a panel already opened for this person, e.g. by "+" with a call context + const personnelActions = usePersonnelActionsStore.getState(); + if (!personnelActions.isActionsOpen || personnelActions.selectedPersonnel?.UserId !== currentId) { + openPersonnelActions(selectedPersonnel); + } setActiveTab('actions'); // Automatically switch to actions tab } else if (prevId && !currentId) { // Only close if we had a selection before and now we don't @@ -383,7 +395,11 @@ const ActivityLogPanelComponent: React.FC = ({ prevSelectedUnitIdRef.current = currentId; if (selectedUnit && currentId) { - openUnitActions(selectedUnit); + // Don't re-open (and wipe) a panel already opened for this unit, e.g. by "+" with a call context + const unitActions = useUnitActionsStore.getState(); + if (!unitActions.isActionsOpen || unitActions.selectedUnit?.UnitId !== currentId) { + openUnitActions(selectedUnit); + } setActiveTab('actions'); // Automatically switch to actions tab } else if (prevId && !currentId) { // Only close if we had a selection before and now we don't @@ -392,6 +408,21 @@ const ActivityLogPanelComponent: React.FC = ({ } }, [selectedUnit, selectedUnitId, openUnitActions, closeUnitActions]); + // Surface the actions panel when it is opened from an explicit call context ("+" set-status-for-call) + useEffect(() => { + const prev = prevActionsSessionIdsRef.current; + if (prev.unit === unitActionsSessionId && prev.personnel === personnelActionsSessionId) return; + prevActionsSessionIdsRef.current = { unit: unitActionsSessionId, personnel: personnelActionsSessionId }; + + const openedForCall = (prev.unit !== unitActionsSessionId && !!useUnitActionsStore.getState().callContext) || (prev.personnel !== personnelActionsSessionId && !!usePersonnelActionsStore.getState().callContext); + if (!openedForCall) return; + + setActiveTab('actions'); + if (isCollapsed) { + setCardCollapsed('activity-log', false); + } + }, [unitActionsSessionId, personnelActionsSessionId, isCollapsed, setCardCollapsed]); + // Filter entries when call filter is active const filteredEntries = useMemo( () => (isCallFilterActive && selectedCallId ? entries.filter((entry) => entry.metadata?.callId === selectedCallId || entry.type === 'system') : entries), @@ -496,6 +527,7 @@ const ActivityLogPanelComponent: React.FC = ({ maxToRenderPerBatch={15} windowSize={7} removeClippedSubviews={Platform.OS !== 'web'} + ListFooterComponent={} /> ); } diff --git a/src/components/dispatch-console/personnel-actions-panel.tsx b/src/components/dispatch-console/personnel-actions-panel.tsx index 2208d1f9..7f0c0371 100644 --- a/src/components/dispatch-console/personnel-actions-panel.tsx +++ b/src/components/dispatch-console/personnel-actions-panel.tsx @@ -1,5 +1,5 @@ import { Building2, Check, ChevronDown, ChevronRight, ChevronUp, MapPinned, Phone, Send, User, X, Zap } from 'lucide-react-native'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native'; @@ -15,7 +15,7 @@ import { Icon } from '@/components/ui/icon'; import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; -import { type DestinationTab, getDefaultDestinationTab, getDestinationCapabilities } from '@/lib/destination-helpers'; +import { type DestinationTab, getDefaultDestinationTab, getEffectiveDestinationType, getStatusDestinationCapabilities, resolveDefaultDestinationCall } from '@/lib/destination-helpers'; import { getPoiSelectionLabel } from '@/lib/poi-display'; import { invertColor, isCallActive } from '@/lib/utils'; import { type CallResultData } from '@/models/v4/calls/callResultData'; @@ -24,6 +24,7 @@ import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; import { useCallsStore } from '@/stores/calls/store'; +import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store'; import { usePersonnelActionsStore } from '@/stores/dispatch/personnel-actions-store'; interface PersonnelActionsPanelProps { @@ -122,9 +123,19 @@ export const PersonnelActionsPanel: React.FC = ({ pe const [localSelectedStatus, setLocalSelectedStatus] = useState(null); const [localSelectedStaffing, setLocalSelectedStaffing] = useState(null); + // The console's selected call — a default destination when the person's own destination is not an active call + const selectedCallId = useDispatchConsoleStore((state) => state.selectedCallId); + + // Whether the destination options (stations/POIs) have finished loading + const [areOptionsLoaded, setAreOptionsLoaded] = useState(false); + // Store state const { selectedPersonnel: storeSelectedPersonnel, + callContext, + actionsSessionId, + destinationInitializedSessionId, + markDestinationInitialized, selectedStatus: storeSelectedStatus, statusDestinationType, statusSelectedCall, @@ -196,10 +207,14 @@ export const PersonnelActionsPanel: React.FC = ({ pe // Load options when panel opens useEffect(() => { + let cancelled = false; + const loadOptions = async () => { setIsLoadingOptions(true); try { const [statusesResult, staffingsResult, groupsResult, poisResult] = await Promise.all([getAllPersonnelStatuses(), getAllPersonnelStaffings(), getAllGroups(), getPois({ destinationOnly: true })]); + // A newer load superseded this one (or the panel unmounted) — don't apply stale options. + if (cancelled) return; if (statusesResult?.Data) { setAvailableStatuses(statusesResult.Data); @@ -217,77 +232,84 @@ export const PersonnelActionsPanel: React.FC = ({ pe } catch (error) { console.error('Failed to load personnel action options:', error); } finally { - setIsLoadingOptions(false); + if (!cancelled) { + setIsLoadingOptions(false); + setAreOptionsLoaded(true); + } } }; if (selectedPersonnel) { loadOptions(); } + + return () => { + cancelled = true; + }; }, [selectedPersonnel, setAvailableStatuses, setAvailableStaffings, setAvailableStations, setAvailablePois, setIsLoadingOptions]); + // Active calls for destination selection and default-destination resolution + const activeCalls = useMemo(() => { + return calls.filter((c) => isCallActive(c.State)); + }, [calls]); + // Update available calls from calls store useEffect(() => { - const activeCalls = calls.filter((c) => isCallActive(c.State)); setAvailableCalls(activeCalls); - }, [calls, setAvailableCalls]); - - // Track the last personnel ID we initialized destination for - const lastInitializedPersonnelIdRef = useRef(null); + }, [activeCalls, setAvailableCalls]); - // Initialize destination from selected personnel's current destination (only once per personnel) + // Apply the default destination once per open of the actions panel (openActions starts a new session: + // a personnel selection, a re-selection or a "+" set-status-for-call). A successful submit does not start + // a new session, so the destination stays sticky for follow-up statuses of the same person. The applied + // session lives in the store so remounting the panel (e.g. switching tabs) does not re-apply it. useEffect(() => { - if (!selectedPersonnel) { - lastInitializedPersonnelIdRef.current = null; - return; - } - - // Only initialize once per personnel - skip if we already initialized for this person - if (lastInitializedPersonnelIdRef.current === selectedPersonnel.UserId) { + if (!selectedPersonnel) return; + // Wait until the store has been opened for this person (the parent opens it after selection changes). + if (storeSelectedPersonnel?.UserId !== selectedPersonnel.UserId) return; + if (destinationInitializedSessionId === actionsSessionId) return; + + // (a) explicit call context, (b) the person's current destination if it is an active call, (c) the console's selected call + const defaultCall = resolveDefaultDestinationCall({ + callContext, + currentDestinationId: selectedPersonnel.StatusDestinationId, + selectedCallId, + activeCalls, + }); + if (defaultCall) { + setStatusSelectedCall(defaultCall); + markDestinationInitialized(actionsSessionId); return; } - // If no destination set, just mark as initialized - if (!selectedPersonnel.StatusDestinationId) { - lastInitializedPersonnelIdRef.current = selectedPersonnel.UserId; - return; - } + // Stations and POIs come from the options load; wait for it before settling on a default. + if (!areOptionsLoaded) return; const destinationId = selectedPersonnel.StatusDestinationId; + const matchingStation = destinationId ? availableStations.find((s) => s.GroupId === destinationId) : undefined; + const matchingPoi = destinationId && !matchingStation ? availablePois.find((poi) => poi.PoiId.toString() === destinationId) : undefined; - // Check if the destination is a call (check available calls) - const matchingCall = calls.find((c) => c.CallId === destinationId); - if (matchingCall) { - setStatusDestinationType('call'); - setStatusSelectedCall(matchingCall); - setStatusSelectedStation(null); - lastInitializedPersonnelIdRef.current = selectedPersonnel.UserId; - return; - } - - // Check if the destination is a station (check available stations) - const matchingStation = availableStations.find((s) => s.GroupId === destinationId); if (matchingStation) { - setStatusDestinationType('station'); setStatusSelectedStation(matchingStation); - setStatusSelectedCall(null); - lastInitializedPersonnelIdRef.current = selectedPersonnel.UserId; - return; - } - - const matchingPoi = availablePois.find((poi) => poi.PoiId.toString() === destinationId); - if (matchingPoi) { - setStatusDestinationType('poi'); + } else if (matchingPoi) { setStatusSelectedPoi(matchingPoi); - lastInitializedPersonnelIdRef.current = selectedPersonnel.UserId; - return; - } - - // If we couldn't match but have data loaded, mark as initialized anyway - if (calls.length > 0 || availableStations.length > 0 || availablePois.length > 0) { - lastInitializedPersonnelIdRef.current = selectedPersonnel.UserId; } - }, [selectedPersonnel, calls, availableStations, availablePois, setStatusDestinationType, setStatusSelectedCall, setStatusSelectedStation, setStatusSelectedPoi]); + markDestinationInitialized(actionsSessionId); + }, [ + selectedPersonnel, + storeSelectedPersonnel, + callContext, + actionsSessionId, + destinationInitializedSessionId, + selectedCallId, + activeCalls, + areOptionsLoaded, + availableStations, + availablePois, + markDestinationInitialized, + setStatusSelectedCall, + setStatusSelectedStation, + setStatusSelectedPoi, + ]); const handleSubmitStatus = useCallback(async () => { // Pass current personnel and status directly to avoid state sync issues @@ -313,23 +335,40 @@ export const PersonnelActionsPanel: React.FC = ({ pe } }, [storeSubmitStaffing, selectedPersonnel, localSelectedStaffing, onStaffingUpdated]); + // An explicit call context ("+" set-status-for-call) may carry its call with any status + const hasCallContext = !!callContext; + + const destinationSelection = useMemo( + () => ({ + selectedDestinationType: statusDestinationType, + selectedCall: statusSelectedCall, + selectedStation: statusSelectedStation, + selectedPoi: statusSelectedPoi, + }), + [statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi] + ); + + // The destination that will actually be sent with the selected status (a destination the status + // does not support is dropped), so the panel shows exactly what will be saved. + const effectiveDestinationType = useMemo(() => getEffectiveDestinationType(destinationSelection, selectedStatus?.Detail, hasCallContext), [destinationSelection, selectedStatus, hasCallContext]); + // Get destination display text const getDestinationDisplay = useMemo(() => { - if (statusDestinationType === 'call' && statusSelectedCall) { + if (effectiveDestinationType === 'call' && statusSelectedCall) { return `#${statusSelectedCall.Number} - ${statusSelectedCall.Name}`; } - if (statusDestinationType === 'station' && statusSelectedStation) { + if (effectiveDestinationType === 'station' && statusSelectedStation) { return statusSelectedStation.Name; } - if (statusDestinationType === 'poi' && statusSelectedPoi) { + if (effectiveDestinationType === 'poi' && statusSelectedPoi) { return getPoiSelectionLabel(statusSelectedPoi); } return t('dispatch.personnel_actions.no_destination'); - }, [statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, t]); + }, [effectiveDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, t]); const destinationConfig = useMemo(() => { - return getDestinationCapabilities(selectedStatus?.Detail); - }, [selectedStatus]); + return getStatusDestinationCapabilities(selectedStatus?.Detail, hasCallContext); + }, [selectedStatus, hasCallContext]); // Check note requirement based on Note field // Note: 0 = No note, 1 = Optional, 2 = Required @@ -365,22 +404,11 @@ export const PersonnelActionsPanel: React.FC = ({ pe return true; }, [selectedStaffing, staffingNote]); - // Active calls for destination selection - const activeCalls = useMemo(() => { - const filtered = calls.filter((c) => isCallActive(c.State)); - console.log('[PersonnelActionsPanel] Active calls:', { - totalCalls: calls.length, - activeCalls: filtered.length, - allStates: calls.map((c) => c.State), - }); - return filtered; - }, [calls]); - useEffect(() => { if (selectedStatus) { - setDestinationTab(getDefaultDestinationTab(selectedStatus.Detail)); + setDestinationTab(hasCallContext ? 'calls' : getDefaultDestinationTab(selectedStatus.Detail)); } - }, [selectedStatus]); + }, [selectedStatus, hasCallContext]); // Refresh calls when destination sheet opens useEffect(() => { @@ -393,8 +421,10 @@ export const PersonnelActionsPanel: React.FC = ({ pe const handleStatusSelect = (status: StatusesResultData) => { setSelectedStatus(status); setIsStatusSheetOpen(false); - // If status requires destination, open destination sheet - if (getDestinationCapabilities(status.Detail).supportsDestination) { + // If the status takes a destination and none applies yet, open the destination sheet. A preset or + // sticky destination the status supports (e.g. the call) is kept and shown instead. + const supportsDestination = getStatusDestinationCapabilities(status.Detail, hasCallContext).supportsDestination; + if (supportsDestination && getEffectiveDestinationType(destinationSelection, status.Detail, hasCallContext) === 'none') { setTimeout(() => setIsDestinationSheetOpen(true), 300); } }; @@ -408,10 +438,8 @@ export const PersonnelActionsPanel: React.FC = ({ pe // Handle destination selection const handleDestinationSelect = (type: 'none' | 'call' | 'station' | 'poi', item?: CallResultData | GroupResultData | PoiResultData) => { if (type === 'none') { + // Clears the call, station and POI selections too setStatusDestinationType('none'); - setStatusSelectedCall(null); - setStatusSelectedStation(null); - setStatusSelectedPoi(null); } else if (type === 'call' && item) { setStatusSelectedCall(item as CallResultData); } else if (type === 'station' && item) { @@ -484,7 +512,7 @@ export const PersonnelActionsPanel: React.FC = ({ pe setIsDestinationSheetOpen(true)}> - + {t('dispatch.personnel_actions.destination')} @@ -633,7 +661,7 @@ export const PersonnelActionsPanel: React.FC = ({ pe {t('dispatch.personnel_actions.destination')} {/* No Destination Option */} - handleDestinationSelect('none')} label={t('dispatch.personnel_actions.no_destination')} /> + handleDestinationSelect('none')} label={t('dispatch.personnel_actions.no_destination')} /> {/* Tabs for Calls and Stations */} {destinationConfig.showCalls || destinationConfig.showStations || destinationConfig.showPois ? ( @@ -673,7 +701,7 @@ export const PersonnelActionsPanel: React.FC = ({ pe key={call.CallId} type="call" item={call} - isSelected={statusDestinationType === 'call' && statusSelectedCall?.CallId === call.CallId} + isSelected={effectiveDestinationType === 'call' && statusSelectedCall?.CallId === call.CallId} onSelect={() => handleDestinationSelect('call', call)} /> )) @@ -692,7 +720,7 @@ export const PersonnelActionsPanel: React.FC = ({ pe key={station.GroupId} type="station" item={station} - isSelected={statusDestinationType === 'station' && statusSelectedStation?.GroupId === station.GroupId} + isSelected={effectiveDestinationType === 'station' && statusSelectedStation?.GroupId === station.GroupId} onSelect={() => handleDestinationSelect('station', station)} /> )) @@ -710,7 +738,7 @@ export const PersonnelActionsPanel: React.FC = ({ pe key={poi.PoiId} type="poi" item={poi} - isSelected={statusDestinationType === 'poi' && statusSelectedPoi?.PoiId === poi.PoiId} + isSelected={effectiveDestinationType === 'poi' && statusSelectedPoi?.PoiId === poi.PoiId} onSelect={() => handleDestinationSelect('poi', poi)} /> )) diff --git a/src/components/dispatch-console/unit-actions-panel.tsx b/src/components/dispatch-console/unit-actions-panel.tsx index 101c355b..c2834666 100644 --- a/src/components/dispatch-console/unit-actions-panel.tsx +++ b/src/components/dispatch-console/unit-actions-panel.tsx @@ -1,5 +1,5 @@ import { Building2, Check, ChevronDown, ChevronRight, ChevronUp, MapPinned, Phone, Send, Truck, X } from 'lucide-react-native'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native'; @@ -13,7 +13,7 @@ import { Icon } from '@/components/ui/icon'; import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; -import { type DestinationTab, getDefaultDestinationTab, getDestinationCapabilities } from '@/lib/destination-helpers'; +import { type DestinationTab, getDefaultDestinationTab, getEffectiveDestinationType, getStatusDestinationCapabilities, resolveDefaultDestinationCall } from '@/lib/destination-helpers'; import { getPoiSelectionLabel } from '@/lib/poi-display'; import { resolveUnitStatusOptions } from '@/lib/unit-status-helpers'; import { invertColor, isCallActive } from '@/lib/utils'; @@ -23,6 +23,7 @@ import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; import { useCallsStore } from '@/stores/calls/store'; +import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store'; import { useUnitActionsStore } from '@/stores/dispatch/unit-actions-store'; import { useUnitsStore } from '@/stores/units/store'; @@ -124,9 +125,19 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr // Local state for selected status (to fix synchronization issues) const [localSelectedStatus, setLocalSelectedStatus] = useState(null); + // The console's selected call — a default destination when the unit's own destination is not an active call + const selectedCallId = useDispatchConsoleStore((state) => state.selectedCallId); + + // Whether the per-unit options (stations/POIs) have finished loading for the given unit + const [optionsLoadedForUnitId, setOptionsLoadedForUnitId] = useState(null); + // Store state const { selectedUnit: storeSelectedUnit, + callContext, + actionsSessionId, + destinationInitializedSessionId, + markDestinationInitialized, selectedStatus: storeSelectedStatus, statusDestinationType, statusSelectedCall, @@ -178,6 +189,8 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr useEffect(() => { if (!selectedUnit) return; + let cancelled = false; + const loadOptions = async () => { setIsLoadingOptions(true); try { @@ -188,6 +201,8 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr await useUnitsStore.getState().fetchUnits(); groups = useUnitsStore.getState().unitStatuses; } + // A newer unit was selected while this load was in flight — don't overwrite its options. + if (cancelled) return; const serverStatuses = (unitStatusData?.Data?.Statuses as unknown as StatusesResultData[]) || []; setAvailableStatuses(resolveUnitStatusOptions(selectedUnit, groups, serverStatuses)); setAvailableCalls(unitStatusData?.Data?.Calls || []); @@ -196,77 +211,82 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr } catch (error) { console.error('Failed to load unit action options:', error); } finally { - setIsLoadingOptions(false); + if (!cancelled) { + setIsLoadingOptions(false); + setOptionsLoadedForUnitId(selectedUnit.UnitId); + } } }; - if (selectedUnit) { - loadOptions(); - } + loadOptions(); + + return () => { + cancelled = true; + }; }, [selectedUnit, setAvailableStatuses, setAvailableCalls, setAvailableStations, setAvailablePois, setIsLoadingOptions]); + // Active calls for destination selection and default-destination resolution + const activeCalls = useMemo(() => { + return calls.filter((c) => isCallActive(c.State)); + }, [calls]); + // Update available calls from calls store useEffect(() => { - const activeCalls = calls.filter((c) => isCallActive(c.State)); setAvailableCalls(activeCalls); - }, [calls, setAvailableCalls]); + }, [activeCalls, setAvailableCalls]); - // Track the last unit ID we initialized destination for - const lastInitializedUnitIdRef = useRef(null); - - // Initialize destination from selected unit's current destination (only once per unit) + // Apply the default destination once per open of the actions panel (openActions starts a new session: + // a unit selection, a re-selection or a "+" set-status-for-call). A successful submit does not start a + // new session, so the destination stays sticky for follow-up statuses of the same unit. The applied + // session lives in the store so remounting the panel (e.g. switching tabs) does not re-apply it. useEffect(() => { - if (!selectedUnit) { - lastInitializedUnitIdRef.current = null; - return; - } - - // Only initialize once per unit - skip if we already initialized for this unit - if (lastInitializedUnitIdRef.current === selectedUnit.UnitId) { + if (!selectedUnit) return; + // Wait until the store has been opened for this unit (the parent opens it after selection changes). + if (storeSelectedUnit?.UnitId !== selectedUnit.UnitId) return; + if (destinationInitializedSessionId === actionsSessionId) return; + + // (a) explicit call context, (b) the unit's current destination if it is an active call, (c) the console's selected call + const defaultCall = resolveDefaultDestinationCall({ + callContext, + currentDestinationId: selectedUnit.CurrentDestinationId, + selectedCallId, + activeCalls, + }); + if (defaultCall) { + setStatusSelectedCall(defaultCall); + markDestinationInitialized(actionsSessionId); return; } - // If no destination set, just mark as initialized - if (!selectedUnit.CurrentDestinationId) { - lastInitializedUnitIdRef.current = selectedUnit.UnitId; - return; - } + // Stations and POIs come from the per-unit options load; wait for it before settling on a default. + if (optionsLoadedForUnitId !== selectedUnit.UnitId) return; const destinationId = selectedUnit.CurrentDestinationId; + const matchingStation = destinationId ? availableStations.find((s) => s.GroupId === destinationId) : undefined; + const matchingPoi = destinationId && !matchingStation ? availablePois.find((poi) => poi.PoiId.toString() === destinationId) : undefined; - // Check if the destination is a call (check available calls) - const matchingCall = calls.find((c) => c.CallId === destinationId); - if (matchingCall) { - setStatusDestinationType('call'); - setStatusSelectedCall(matchingCall); - setStatusSelectedStation(null); - lastInitializedUnitIdRef.current = selectedUnit.UnitId; - return; - } - - // Check if the destination is a station (check available stations) - const matchingStation = availableStations.find((s) => s.GroupId === destinationId); if (matchingStation) { - setStatusDestinationType('station'); setStatusSelectedStation(matchingStation); - setStatusSelectedCall(null); - lastInitializedUnitIdRef.current = selectedUnit.UnitId; - return; - } - - const matchingPoi = availablePois.find((poi) => poi.PoiId.toString() === destinationId); - if (matchingPoi) { - setStatusDestinationType('poi'); + } else if (matchingPoi) { setStatusSelectedPoi(matchingPoi); - lastInitializedUnitIdRef.current = selectedUnit.UnitId; - return; } - - // If we couldn't match but have data loaded, mark as initialized anyway - if (calls.length > 0 || availableStations.length > 0 || availablePois.length > 0) { - lastInitializedUnitIdRef.current = selectedUnit.UnitId; - } - }, [selectedUnit, calls, availableStations, availablePois, setStatusDestinationType, setStatusSelectedCall, setStatusSelectedStation, setStatusSelectedPoi]); + markDestinationInitialized(actionsSessionId); + }, [ + selectedUnit, + storeSelectedUnit, + callContext, + actionsSessionId, + destinationInitializedSessionId, + selectedCallId, + activeCalls, + optionsLoadedForUnitId, + availableStations, + availablePois, + markDestinationInitialized, + setStatusSelectedCall, + setStatusSelectedStation, + setStatusSelectedPoi, + ]); const handleSubmitStatus = useCallback(async () => { // Pass current unit and status directly to avoid state sync issues @@ -280,24 +300,41 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr } }, [storeSubmitStatus, selectedUnit, localSelectedStatus, onStatusUpdated]); + // An explicit call context ("+" set-status-for-call) may carry its call with any status + const hasCallContext = !!callContext; + + const destinationSelection = useMemo( + () => ({ + selectedDestinationType: statusDestinationType, + selectedCall: statusSelectedCall, + selectedStation: statusSelectedStation, + selectedPoi: statusSelectedPoi, + }), + [statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi] + ); + + // The destination that will actually be sent with the selected status (a destination the status + // does not support is dropped), so the panel shows exactly what will be saved. + const effectiveDestinationType = useMemo(() => getEffectiveDestinationType(destinationSelection, selectedStatus?.Detail, hasCallContext), [destinationSelection, selectedStatus, hasCallContext]); + // Get destination display text const getDestinationDisplay = useMemo(() => { - if (statusDestinationType === 'call' && statusSelectedCall) { + if (effectiveDestinationType === 'call' && statusSelectedCall) { return `#${statusSelectedCall.Number} - ${statusSelectedCall.Name}`; } - if (statusDestinationType === 'station' && statusSelectedStation) { + if (effectiveDestinationType === 'station' && statusSelectedStation) { return statusSelectedStation.Name; } - if (statusDestinationType === 'poi' && statusSelectedPoi) { + if (effectiveDestinationType === 'poi' && statusSelectedPoi) { return getPoiSelectionLabel(statusSelectedPoi); } return t('dispatch.unit_actions_panel.no_destination'); - }, [statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, t]); + }, [effectiveDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, t]); // Check destination type allowed based on Detail const destinationConfig = useMemo(() => { - return getDestinationCapabilities(selectedStatus?.Detail); - }, [selectedStatus]); + return getStatusDestinationCapabilities(selectedStatus?.Detail, hasCallContext); + }, [selectedStatus, hasCallContext]); // Check note requirement based on Note field // Note: 0 = No note, 1 = Optional, 2 = Required @@ -317,16 +354,11 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr return true; }, [selectedStatus, statusNote]); - // Active calls for destination selection - const activeCalls = useMemo(() => { - return calls.filter((c) => isCallActive(c.State)); - }, [calls]); - useEffect(() => { if (selectedStatus) { - setDestinationTab(getDefaultDestinationTab(selectedStatus.Detail)); + setDestinationTab(hasCallContext ? 'calls' : getDefaultDestinationTab(selectedStatus.Detail)); } - }, [selectedStatus]); + }, [selectedStatus, hasCallContext]); // Refresh calls when destination sheet opens useEffect(() => { @@ -339,8 +371,10 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr const handleStatusSelect = (status: StatusesResultData) => { setSelectedStatus(status); setIsStatusSheetOpen(false); - // If status requires destination, open destination sheet - if (getDestinationCapabilities(status.Detail).supportsDestination) { + // If the status takes a destination and none applies yet, open the destination sheet. A preset or + // sticky destination the status supports (e.g. the call) is kept and shown instead. + const supportsDestination = getStatusDestinationCapabilities(status.Detail, hasCallContext).supportsDestination; + if (supportsDestination && getEffectiveDestinationType(destinationSelection, status.Detail, hasCallContext) === 'none') { setTimeout(() => setIsDestinationSheetOpen(true), 300); } }; @@ -348,10 +382,8 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr // Handle destination selection const handleDestinationSelect = (type: 'none' | 'call' | 'station' | 'poi', item?: CallResultData | GroupResultData | PoiResultData) => { if (type === 'none') { + // Clears the call, station and POI selections too setStatusDestinationType('none'); - setStatusSelectedCall(null); - setStatusSelectedStation(null); - setStatusSelectedPoi(null); } else if (type === 'call' && item) { setStatusSelectedCall(item as CallResultData); } else if (type === 'station' && item) { @@ -424,7 +456,7 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr setIsDestinationSheetOpen(true)}> - + {t('dispatch.unit_actions_panel.destination')} @@ -506,7 +538,7 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr {t('dispatch.unit_actions_panel.destination')} {/* No Destination Option */} - handleDestinationSelect('none')} label={t('dispatch.unit_actions_panel.no_destination')} /> + handleDestinationSelect('none')} label={t('dispatch.unit_actions_panel.no_destination')} /> {/* Tabs for Calls and Stations */} {destinationConfig.showCalls || destinationConfig.showStations || destinationConfig.showPois ? ( @@ -546,7 +578,7 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr key={call.CallId} type="call" item={call} - isSelected={statusDestinationType === 'call' && statusSelectedCall?.CallId === call.CallId} + isSelected={effectiveDestinationType === 'call' && statusSelectedCall?.CallId === call.CallId} onSelect={() => handleDestinationSelect('call', call)} /> )) @@ -565,7 +597,7 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr key={station.GroupId} type="station" item={station} - isSelected={statusDestinationType === 'station' && statusSelectedStation?.GroupId === station.GroupId} + isSelected={effectiveDestinationType === 'station' && statusSelectedStation?.GroupId === station.GroupId} onSelect={() => handleDestinationSelect('station', station)} /> )) @@ -583,7 +615,7 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr key={poi.PoiId} type="poi" item={poi} - isSelected={statusDestinationType === 'poi' && statusSelectedPoi?.PoiId === poi.PoiId} + isSelected={effectiveDestinationType === 'poi' && statusSelectedPoi?.PoiId === poi.PoiId} onSelect={() => handleDestinationSelect('poi', poi)} /> )) diff --git a/src/components/notifications/NotificationDetail.tsx b/src/components/notifications/NotificationDetail.tsx index c3cea735..218ac69b 100644 --- a/src/components/notifications/NotificationDetail.tsx +++ b/src/components/notifications/NotificationDetail.tsx @@ -1,6 +1,7 @@ import { useNotifications } from '@novu/react-native'; import { useColorScheme } from 'nativewind'; import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { Animated, Dimensions, Platform, Pressable, SafeAreaView, StatusBar, type StyleProp, StyleSheet, Text, View, type ViewStyle } from 'react-native'; import { ArrowLeft, Calendar, ExternalLink, Trash2 } from '@/components/ui/lucide-icons'; @@ -30,6 +31,7 @@ const SIDEBAR_WIDTH = Math.min(width * 0.85, 400); const STATUS_BAR_HEIGHT = Platform.OS === 'ios' ? 44 : StatusBar.currentHeight || 0; export const NotificationDetail = ({ notification, onClose, onDelete, onNavigateToReference }: NotificationDetailProps) => { + const { t } = useTranslation(); const styles = useStyles(); const { refetch } = useNotifications(); const slideAnim = React.useRef(new Animated.Value(SIDEBAR_WIDTH)).current; @@ -111,7 +113,7 @@ export const NotificationDetail = ({ notification, onClose, onDelete, onNavigate - Notification + {t('notifications.detail_title')} @@ -140,7 +142,7 @@ export const NotificationDetail = ({ notification, onClose, onDelete, onNavigate {notification.metadata && Object.keys(notification.metadata).length > 0 ? ( - Additional Information + {t('notifications.additional_info')} {Object.entries(notification.metadata).map(([key, value]) => ( {formatKey(key)}: @@ -151,9 +153,11 @@ export const NotificationDetail = ({ notification, onClose, onDelete, onNavigate ) : null} {notification.referenceType && notification.referenceId ? ( - + - View {notification.referenceType} + + {notification.referenceType === 'call' ? t('notifications.view_call') : notification.referenceType === 'chat' ? t('notifications.view_chat') : t('notifications.open_reference')} + ) : null} diff --git a/src/components/notifications/NotificationInbox.tsx b/src/components/notifications/NotificationInbox.tsx index e0e4cd87..78eceab9 100644 --- a/src/components/notifications/NotificationInbox.tsx +++ b/src/components/notifications/NotificationInbox.tsx @@ -1,4 +1,5 @@ import { useNotifications } from '@novu/react-native'; +import { router } from 'expo-router'; import { useColorScheme } from 'nativewind'; import React, { useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Animated, Dimensions, Platform, Pressable, RefreshControl, SafeAreaView, StatusBar, StyleSheet, View } from 'react-native'; @@ -10,6 +11,8 @@ import { FlatList } from '@/components/ui/flat-list'; import { CheckCircle, ChevronRight, Circle, ExternalLink, MoreVertical, Trash2, X } from '@/components/ui/lucide-icons'; import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/ui/modal'; import { Text } from '@/components/ui/text'; +import { useAuthStore } from '@/lib/auth'; +import { referenceFromEventCode, referenceHref } from '@/lib/notifications/inbox-reference'; import { useCoreStore } from '@/stores/app/core-store'; import { useToastStore } from '@/stores/toast/store'; import { type NotificationPayload } from '@/types/notification'; @@ -24,9 +27,42 @@ interface NotificationInboxProps { onClose: () => void; } +/** The notification item shape returned by Novu's useNotifications hook. */ +type NovuNotification = NonNullable['notifications']>[number]; + +const REFERENCE_TYPES = ['call', 'message', 'status', 'note', 'chat', 'other'] as const; + +const asString = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined); + +const asReferenceType = (value: unknown): NotificationPayload['referenceType'] => REFERENCE_TYPES.find((candidate) => candidate === value); + +/** + * Maps a Novu inbox item (@novu/js v3: subject, isRead, data). The v2 names this inbox used to read + * (title, read, payload) are undefined or, for `read`, a method, so every row looked read, had no title + * and never carried a reference. The Novu bridge puts the push event code in `data`, from which the call + * or chat the notification is about is derived; the code itself is routing, not "Additional Information". + */ +export const mapNovuNotification = (item: NovuNotification): NotificationPayload => { + const data = item.data; + const reference = referenceFromEventCode(data?.eventCode); + + return { + id: item.id, + title: item.subject, + body: item.body, + createdAt: item.createdAt, + read: item.isRead, + type: asString(data?.type), + referenceId: reference?.referenceId ?? asString(data?.referenceId), + referenceType: reference?.referenceType ?? asReferenceType(data?.referenceType), + metadata: data ? Object.fromEntries(Object.entries(data).filter(([key]) => key !== 'eventCode')) : undefined, + }; +}; + export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) => { const styles = useStyles(); - const activeUnitId = useCoreStore((state) => state.activeUnitId); + // The inbox is the dispatcher's own ({code}_User_{id}, the NovuProvider subscriber), not a unit's. + const userId = useAuthStore((state) => state.userId); const config = useCoreStore((state: any) => state.config); const { notifications, isLoading, fetchMore, hasMore, refetch } = useNotifications(); const showToast = useToastStore((state) => state.showToast); @@ -155,23 +191,15 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = ); const handleNavigateToReference = (referenceType: string, referenceId: string) => { - // TODO: Implement navigation based on reference type - console.log('Navigate to:', referenceType, referenceId); + const href = referenceHref(referenceType, referenceId); + setSelectedNotification(null); onClose(); + if (href) router.push(href); }; - const renderItem = ({ item }: { item: any }) => { - const notification: NotificationPayload = { - id: item.id, - title: item.title, - body: item.body, - createdAt: item.createdAt, - read: item.read, - type: item.type, - referenceId: item.payload?.referenceId, - referenceType: item.payload?.referenceType, - metadata: item.payload?.metadata, - }; + const renderItem = ({ item }: { item: NovuNotification }) => { + const notification = mapNovuNotification(item); + const unread = !notification.read; const isSelected = selectedNotificationIds.has(notification.id); @@ -184,9 +212,9 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = toggleNotificationSelection(notification.id); } }} - style={[styles.notificationItem, !item.read ? styles.unreadNotificationItem : {}, isSelected ? styles.selectedNotificationItem : {}]} + style={[styles.notificationItem, unread ? styles.unreadNotificationItem : {}, isSelected ? styles.selectedNotificationItem : {}]} > - {!item.read ? : null} + {unread ? : null} {isSelectionMode ? ( @@ -195,7 +223,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = ) : null} - {notification.body} + {notification.body} {new Date(notification.createdAt).toLocaleDateString()} {new Date(notification.createdAt).toLocaleTimeString()} @@ -204,7 +232,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = {!isSelectionMode ? ( notification.referenceType && notification.referenceId ? ( - @@ -238,7 +266,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = } // Additional safety check to prevent rendering overlay without proper config - if (!activeUnitId || !config || !config.NovuApplicationId || !config.NovuBackendApiUrl || !config.NovuSocketUrl) { + if (!userId || !config || !config.NovuApplicationId || !config.NovuBackendApiUrl || !config.NovuSocketUrl) { return null; } @@ -293,7 +321,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = - ) : !activeUnitId || !config ? ( + ) : !userId || !config ? ( Unable to load notifications diff --git a/src/components/notifications/__tests__/notification-references.test.tsx b/src/components/notifications/__tests__/notification-references.test.tsx new file mode 100644 index 00000000..2ddf4054 --- /dev/null +++ b/src/components/notifications/__tests__/notification-references.test.tsx @@ -0,0 +1,106 @@ +/** + * The real inbox and detail with their data sources mocked (the sibling NotificationInbox.test mocks + * the inbox itself). Covers the Novu v2 -> v3 field fix (the inbox read `title`/`read`/`payload`, so rows + * had no title, all looked read and never carried a reference), the inbox opening for a dispatcher who + * has no active unit, and call/chat links derived from the push event code the Novu bridge puts in `data`. + */ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { router } from 'expo-router'; +import React from 'react'; + +jest.mock('expo-router', () => ({ router: { push: jest.fn() } })); +jest.mock('@novu/react-native', () => ({ useNotifications: jest.fn() })); +jest.mock('@/stores/app/core-store', () => ({ useCoreStore: jest.fn() })); +jest.mock('@/stores/toast/store', () => ({ useToastStore: jest.fn() })); +jest.mock('@/lib/auth', () => ({ useAuthStore: jest.fn() })); +jest.mock('@/api/novu/inbox', () => ({ deleteMessage: jest.fn().mockResolvedValue(undefined) })); +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => ({ 'notifications.view_call': 'View Call', 'notifications.view_chat': 'View Chat', 'notifications.open_reference': 'Open', 'notifications.detail_title': 'Notification' })[key] ?? key, + }), +})); + +import { useNotifications } from '@novu/react-native'; + +import { useAuthStore } from '@/lib/auth'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useToastStore } from '@/stores/toast/store'; + +import { NotificationDetail } from '../NotificationDetail'; +import { mapNovuNotification, NotificationInbox } from '../NotificationInbox'; + +type NovuItem = Parameters[0]; +// `read` is a method on a real @novu/js v3 Notification; the old mapping read it as the flag. +const item = (data: Record | undefined, id = 'n-1', isRead = false) => + ({ id, subject: 'Structure Fire', body: 'Engine 6 respond', createdAt: '2026-09-22T10:00:00Z', isRead, read: () => undefined, data }) as unknown as NovuItem; + +const withItems = (items: NovuItem[]) => (useNotifications as jest.Mock).mockReturnValue({ notifications: items, isLoading: false, fetchMore: jest.fn(), hasMore: false, refetch: jest.fn() }); + +beforeEach(() => { + jest.clearAllMocks(); + // A dispatcher: signed in, no active unit. + (useCoreStore as unknown as jest.Mock).mockImplementation((selector: (state: unknown) => unknown) => + selector({ activeUnitId: null, config: { NovuApplicationId: 'app', NovuBackendApiUrl: 'api', NovuSocketUrl: 'socket' } }) + ); + (useAuthStore as unknown as jest.Mock).mockImplementation((selector: (state: unknown) => unknown) => selector({ userId: 'dispatcher-1' })); + (useToastStore as unknown as jest.Mock).mockImplementation((selector: (state: unknown) => unknown) => selector({ showToast: jest.fn() })); +}); + +describe('mapNovuNotification', () => { + it('reads the v3 fields and derives call and chat links from the event code', () => { + expect(mapNovuNotification(item({ eventCode: 'C1234' }))).toEqual(expect.objectContaining({ title: 'Structure Fire', read: false, referenceType: 'call', referenceId: '1234', metadata: {} })); + expect(mapNovuNotification(item({ eventCode: 'g:7f1c' }, 'n-2', true))).toEqual(expect.objectContaining({ read: true, referenceType: 'chat', referenceId: '7f1c' })); + // Work orders open only in the Responder app. + expect(mapNovuNotification(item({ eventCode: 'NWO:0b7c3e52-2f4a-4d0e-9a57-1f7a0c9d6e11' })).referenceType).toBeUndefined(); + }); +}); + +describe('NotificationInbox', () => { + it('opens for a signed-in dispatcher without an active unit', () => { + withItems([item({ eventCode: '' }, 'plain-1')]); + + render(); + + expect(screen.getByText('Engine 6 respond')).toBeTruthy(); + expect(screen.queryByTestId('notification-reference-plain-1')).toBeNull(); + }); + + it('opens the dispatched call and closes the inbox', () => { + withItems([item({ eventCode: 'C1234' }, 'call-1')]); + const onClose = jest.fn(); + + render(); + fireEvent.press(screen.getByTestId('notification-reference-call-1')); + + expect(router.push).toHaveBeenCalledWith({ pathname: '/call/[id]', params: { id: '1234' } }); + expect(onClose).toHaveBeenCalled(); + }); + + it('opens the chat conversation', () => { + withItems([item({ eventCode: 't:9a2b' }, 'chat-1')]); + + render(); + fireEvent.press(screen.getByTestId('notification-reference-chat-1')); + + expect(router.push).toHaveBeenCalledWith({ pathname: '/chat/[channelId]', params: { channelId: '9a2b' } }); + }); +}); + +describe('NotificationDetail reference button', () => { + const base = { id: 'n-1', title: 'Structure Fire', body: 'Engine 6 respond', createdAt: '2026-09-22T10:00:00Z', read: true }; + + it.each([ + ['call', 'View Call'], + ['chat', 'View Chat'], + ['note', 'Open'], + ])('labels a %s reference as %s', (referenceType, label) => { + withItems([]); + const onNavigateToReference = jest.fn(); + render(); + + expect(screen.getByText(label)).toBeTruthy(); + expect(screen.getByText('Notification')).toBeTruthy(); + fireEvent.press(screen.getByTestId('notification-detail-reference')); + expect(onNavigateToReference).toHaveBeenCalledWith(referenceType, 'ref-1'); + }); +}); diff --git a/src/components/operations/expenses-panel.tsx b/src/components/operations/expenses-panel.tsx new file mode 100644 index 00000000..36b75fdc --- /dev/null +++ b/src/components/operations/expenses-panel.tsx @@ -0,0 +1,154 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert } from 'react-native'; + +import { OptionSelect } from '@/components/operations/option-select'; +import { Button, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField } from '@/components/ui/input'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { type CapturedPhoto, capturePhoto, discardPhoto, PhotoPermissionError } from '@/lib/media/photo'; +import { dateOf } from '@/lib/operations/time'; +import type { Expense, ExpenseInput } from '@/models/v4/operations'; +import { ExpenseType } from '@/models/v4/operations'; + +interface ExpensesPanelProps { + dateKey: string; + currency?: string | null; + expenses: Expense[]; + /** The open report the expense belongs to, when the person may act on it. */ + reportId: string | null; + userId: string | null; + canManage: boolean; + canAdd: boolean; + busy: boolean; + onAdd: (input: Omit) => Promise; + onRemove: (id: string) => Promise; +} + +const amountOf = (text: string) => { + const value = Number(text.replace(',', '.')); + return Number.isFinite(value) && value >= 0 ? Math.round(value * 100) / 100 : null; +}; + +// Field expenses against the deployment (fuel, meals, lodging, restock) with a receipt photo. The receipt is +// re-encoded (no GPS or device metadata) and filed by the server as a Receipt attachment; a member removes +// only the expenses they added. +export const ExpensesPanel = ({ dateKey, currency, expenses, reportId, userId, canManage, canAdd, busy, onAdd, onRemove }: ExpensesPanelProps) => { + const { t } = useTranslation(); + const [type, setType] = useState(String(ExpenseType.Fuel)); + const [amount, setAmount] = useState(''); + const [description, setDescription] = useState(''); + const [city, setCity] = useState(''); + const [receipt, setReceipt] = useState(null); + const [message, setMessage] = useState(null); + const typeOptions = Object.values(ExpenseType).map((value) => ({ value: String(value), label: t(`operations.expenses.type.${value}`) })); + const day = expenses.filter((expense) => dateOf(expense.ExpenseDate) === dateKey); + const others = expenses.filter((expense) => dateOf(expense.ExpenseDate) !== dateKey); + + const pick = async (source: 'camera' | 'library') => { + setMessage(null); + try { + const photo = await capturePhoto(source, `receipt-${dateKey}.jpg`); + if (photo) { + await discardPhoto(receipt); + setReceipt(photo); + } + } catch (error) { + setMessage(error instanceof PhotoPermissionError ? t('operations.expenses.photoDenied') : t('operations.errors.retry')); + } + }; + + const add = async () => { + const value = amountOf(amount); + if (value == null) { + setMessage(t('operations.expenses.amountInvalid')); + return; + } + const ok = await onAdd({ + ExpenseDate: dateKey, + ExpenseType: Number(type), + Amount: value, + Currency: currency ?? null, + Description: description.trim() || null, + City: city.trim() || null, + TimeReportId: reportId, + ReceiptData: receipt?.base64 ?? null, + ReceiptFileName: receipt?.name ?? null, + ReceiptContentType: receipt?.contentType ?? null, + }); + if (ok) { + await discardPhoto(receipt); + setReceipt(null); + setAmount(''); + setDescription(''); + setCity(''); + setMessage(t('operations.expenses.saved')); + } + }; + + const confirmRemove = (expense: Expense) => + Alert.alert(t('operations.expenses.removeTitle'), t('operations.expenses.removeBody'), [ + { text: t('operations.expenses.cancel'), style: 'cancel' }, + { text: t('operations.expenses.remove'), style: 'destructive', onPress: () => void onRemove(expense.Id) }, + ]); + + const row = (expense: Expense) => ( + + + + {t(`operations.expenses.type.${expense.ExpenseType}`)} · {expense.Amount.toFixed(2)} {expense.Currency ?? ''} + + + {[dateOf(expense.ExpenseDate), expense.Description, expense.City, expense.ReceiptAttachmentId ? t('operations.expenses.hasReceipt') : null].filter(Boolean).join(' · ')} + + + {canManage || (!!userId && expense.AddedByUserId === userId) ? ( + + ) : null} + + ); + + return ( + + {day.length === 0 ? {t('operations.expenses.noneToday')} : day.map(row)} + {canAdd ? ( + + {t('operations.expenses.add')} + + + + + + + + + + + + + + + {receipt ? {t('operations.expenses.receiptAttached')} : null} + + + ) : null} + {message ? {message} : null} + {others.length > 0 ? ( + + {t('operations.expenses.otherDays')} + {others.map(row)} + + ) : null} + + ); +}; diff --git a/src/components/operations/scope-picker.tsx b/src/components/operations/scope-picker.tsx new file mode 100644 index 00000000..7345ef26 --- /dev/null +++ b/src/components/operations/scope-picker.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView } from 'react-native'; + +import { Button, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { dateOf, reportScope, scopeKey, scopeName, sumHours, type TimeScope } from '@/lib/operations/time'; +import type { Deployment, TimeReport } from '@/models/v4/operations'; + +export const useScopeLabel = () => { + const { t } = useTranslation(); + return (deployment: Deployment, scope: TimeScope | null) => { + if (!scope) return ''; + if (scope.kind === 'deployment') return t('operations.scope.deployment'); + if (scope.kind === 'individual' && scope.personnelId === deployment.TimeAccess?.PersonnelId) return t('operations.scope.mine'); + return scope.kind === 'crew' ? t('operations.scope.crew', { name: scopeName(deployment, scope) }) : t('operations.scope.individual', { name: scopeName(deployment, scope) }); + }; +}; + +interface ScopePickerProps { + deployment: Deployment; + scopes: TimeScope[]; + current: TimeScope | null; + onPick: (scope: TimeScope) => void; +} + +// Which report the person is working on: their crew's Crew Time Report, their own time, or — for a manager — +// the deployment-wide DTR. Only the scopes the server's TimeAccess grants are offered. +export const ScopePicker = ({ deployment, scopes, current, onPick }: ScopePickerProps) => { + const label = useScopeLabel(); + if (scopes.length < 2) return null; + return ( + + {scopes.map((scope) => ( + + ))} + + ); +}; + +interface DayReportsProps { + deployment: Deployment; + reports: TimeReport[]; + openId: string | null; + onOpen: (report: TimeReport) => void; +} + +// Every live report on the day (managers and approvers): who it covers, its status and hours, so a supervisor +// can open each crew's report to review and approve it. +export const DayReports = ({ deployment, reports, openId, onOpen }: DayReportsProps) => { + const { t } = useTranslation(); + const label = useScopeLabel(); + if (reports.length === 0) return null; + return ( + + {t('operations.time.dayReports')} + {reports.map((report) => ( + onOpen(report)} testID={`operations-day-report-${report.Id}`} className={`rounded-lg border p-2 ${report.Id === openId ? 'border-primary-500' : 'border-outline-200'}`}> + + + #{report.ReportNumber} · {label(deployment, reportScope(report))} + + + {t(`operations.reportStatus.${report.Status}`)} · {t('operations.time.hours', { hours: sumHours(report.Entries) })} + + + {dateOf(report.ReportDate)} + + ))} + + ); +}; + +interface AwaitingApprovalProps { + deployment: Deployment; + reports: TimeReport[]; + onOpen: (report: TimeReport) => void; +} + +/** Submitted reports on any day, oldest first: the approver's queue for this deployment. */ +export const AwaitingApproval = ({ deployment, reports, onOpen }: AwaitingApprovalProps) => { + const { t } = useTranslation(); + const label = useScopeLabel(); + if (reports.length === 0) return null; + return ( + + {t('operations.time.awaitingApproval', { count: reports.length })} + {reports.map((report) => ( + onOpen(report)} testID={`operations-awaiting-${report.Id}`}> + + {dateOf(report.ReportDate)} · #{report.ReportNumber} · {label(deployment, reportScope(report))} + + + ))} + + ); +}; diff --git a/src/components/operations/time-report-editor.tsx b/src/components/operations/time-report-editor.tsx index 9b25d5b6..bed50fc4 100644 --- a/src/components/operations/time-report-editor.tsx +++ b/src/components/operations/time-report-editor.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { OptionSelect } from '@/components/operations/option-select'; @@ -9,29 +9,41 @@ import { Switch } from '@/components/ui/switch'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import type { EntrySubject } from '@/lib/operations/time'; -import { combineDateTime, entryHours, isClockTime, isReportEditable, newEntry, subjectLabel, sumHours, timeOf } from '@/lib/operations/time'; +import { applyToCrew, combineDateTime, dateOf, endLocalFor, entryHours, hoursBySubject, isClockTime, isReportEditable, newEntry, subjectIdOf, sumHours, timeOf } from '@/lib/operations/time'; import type { TimeEntry, TimeReport, TimeReportIssue } from '@/models/v4/operations'; -import { TimeEntryType } from '@/models/v4/operations'; +import { TimeEntryType, TimeReportScope, TimeReportStatus, TimeSubjectType } from '@/models/v4/operations'; interface TimeReportEditorProps { dateKey: string; report: TimeReport | null; entries: TimeEntry[]; + /** Subjects the person may add rows for in this report's scope. */ subjects: EntrySubject[]; + /** Roster names by subject id, including released members, for stored rows. */ + names: Record; + /** Short name of the crew or person the report covers ("Engine 41", "A. Smith"); empty for a deployment DTR. */ + scopeLabel: string; + isCrew: boolean; + /** Set when this person's or crew's time for the day already sits on another report. */ + coveredBy: TimeReport | null; issues: TimeReportIssue[]; warnings: TimeReportIssue[]; - canEdit: boolean; + canCreate: boolean; + canApprove: boolean; + canWrite: (subjectId: string) => boolean; dirty: boolean; busy: boolean; onStart: () => void; onChange: (entries: TimeEntry[]) => void; onSave: () => void; onSubmit: () => void; + onSign: (crewBossSigned: boolean, customerSignerName?: string | null) => void; + onApprove: () => void; } // Keeps the keystrokes local and only commits a complete HH:mm, so a half-typed time never lands // in the entry as an unparseable value. -const ClockField = ({ value, onCommit, isDisabled, testID }: { value: string; onCommit: (clock: string) => void; isDisabled: boolean; testID: string }) => { +const ClockField = ({ value, onCommit, isDisabled, testID, label }: { value: string; onCommit: (clock: string) => void; isDisabled: boolean; testID: string; label: string }) => { const [text, setText] = useState(value); useEffect(() => { setText(value); @@ -40,6 +52,7 @@ const ClockField = ({ value, onCommit, isDisabled, testID }: { value: string; on { +const typeRank = (entry: TimeEntry) => (entry.SubjectType === TimeSubjectType.Unit ? 0 : entry.SubjectType === TimeSubjectType.Personnel ? 1 : 2); + +// A crew or individual time report for one day (the paper CTR / OF-288 analog). On a crew report the unit's +// row leads; "Apply to crew" copies its shift to every crew member and piece of equipment on the same entry +// type, which is how most crews fill the form, and rows that differ are edited one by one. The crew boss +// signs, the agency signer's name can be recorded, and the report is submitted for approval. +export const TimeReportEditor = ({ + dateKey, + report, + entries, + subjects, + names, + scopeLabel, + isCrew, + coveredBy, + issues, + warnings, + canCreate, + canApprove, + canWrite, + dirty, + busy, + onStart, + onChange, + onSave, + onSubmit, + onSign, + onApprove, +}: TimeReportEditorProps) => { const { t } = useTranslation(); - const editable = canEdit && isReportEditable(report); + const [customerSigner, setCustomerSigner] = useState(''); + const editable = isReportEditable(report); const typeOptions = [ { value: String(TimeEntryType.Deployment), label: t('operations.entryType.0') }, { value: String(TimeEntryType.Standby), label: t('operations.entryType.1') }, { value: String(TimeEntryType.Travel), label: t('operations.entryType.2') }, ]; const subjectOptions = subjects.map((subject) => ({ value: `${subject.type}:${subject.id}`, label: subject.label })); + // Unit first, then crew, then equipment; indexes still point into the saved order. + const order = useMemo(() => entries.map((entry, index) => ({ entry, index })).sort((a, b) => typeRank(a.entry) - typeRank(b.entry) || a.index - b.index), [entries]); + const perSubject = useMemo(() => hoursBySubject(entries), [entries]); const update = (index: number, patch: Partial) => onChange(entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry))); const remove = (index: number) => onChange(entries.filter((_, i) => i !== index).map((entry, i) => ({ ...entry, SortOrder: i }))); const add = () => { @@ -74,101 +119,152 @@ export const TimeReportEditor = ({ dateKey, report, entries, subjects, issues, w const blank = newEntry(subject, dateKey, index); update(index, { SubjectType: blank.SubjectType, DeploymentPersonnelId: blank.DeploymentPersonnelId, DeploymentUnitId: blank.DeploymentUnitId, DeploymentEquipmentId: blank.DeploymentEquipmentId }); }; + const setStart = (index: number, clock: string) => { + const entry = entries[index]; + const startLocal = combineDateTime(dateOf(entry.StartLocal) || dateKey, clock); + update(index, { StartLocal: startLocal, EndLocal: endLocalFor(startLocal, timeOf(entry.EndLocal) || clock) }); + }; const issueFor = (index: number) => issues.filter((issue) => issue.EntryId === String(index) || (!!entries[index]?.Id && issue.EntryId === entries[index]?.Id)); + const issueText = (issue: TimeReportIssue) => t(`operations.issues.${issue.Code}`, { defaultValue: issue.Detail ?? issue.Code }); + const title = !report + ? '' + : report.Scope === TimeReportScope.Crew + ? t('operations.time.crewReport', { number: report.ReportNumber, name: scopeLabel }) + : report.Scope === TimeReportScope.Individual + ? t('operations.time.individualReport', { number: report.ReportNumber, name: scopeLabel }) + : t('operations.time.report', { number: report.ReportNumber }); if (!report) { return ( - {t('operations.time.noReport')} - {canEdit ? ( + {isCrew ? t('operations.time.noCrewReport', { name: scopeLabel }) : t('operations.time.noReport')} + {coveredBy ? {t('operations.time.coveredBy', { number: coveredBy.ReportNumber })} : null} + {canCreate && !coveredBy ? ( ) : null} ); } + const signable = report.CanAct && (report.Status === TimeReportStatus.Draft || report.Status === TimeReportStatus.Submitted); + return ( - {t('operations.time.report', { number: report.ReportNumber })} + {title} {t(`operations.reportStatus.${report.Status}`)} + {!report.CanAct ? {t('operations.time.readOnly')} : null} {entries.length === 0 ? {t('operations.time.noEntries')} : null} - {entries.map((entry, index) => ( - - setSubject(index, value)} - isDisabled={!editable} - testID={`operations-entry-subject-${index}`} - /> - update(index, { EntryType: Number(value) })} - isDisabled={!editable} - testID={`operations-entry-type-${index}`} - /> - - {t('operations.time.from')} - update(index, { StartTime: combineDateTime(dateKey, clock) })} isDisabled={!editable} testID={`operations-entry-start-${index}`} /> - {t('operations.time.to')} - update(index, { EndTime: combineDateTime(dateKey, clock) })} isDisabled={!editable} testID={`operations-entry-end-${index}`} /> - - - {t('operations.time.unpaidBreak')} - - update(index, { UnpaidBreakMinutes: Math.max(0, Number(text) || 0) })} - testID={`operations-entry-break-${index}`} + {order.map(({ entry, index }) => { + const subjectId = subjectIdOf(entry); + const rowEditable = editable && canWrite(subjectId); + const label = names[subjectId] ?? ''; + return ( + + {entry.Id || !rowEditable ? ( + {label || t('operations.time.subject')} + ) : ( + setSubject(index, value)} + testID={`operations-entry-subject-${index}`} + /> + )} + update(index, { EntryType: Number(value) })} + isDisabled={!rowEditable} + testID={`operations-entry-type-${index}`} + /> + + {t('operations.time.from')} + setStart(index, clock)} isDisabled={!rowEditable} testID={`operations-entry-start-${index}`} /> + {t('operations.time.to')} + update(index, { EndLocal: endLocalFor(entry.StartLocal, clock) })} + isDisabled={!rowEditable} + testID={`operations-entry-end-${index}`} /> + + {dateOf(entry.EndLocal) > dateOf(entry.StartLocal) ? {t('operations.time.nextDay')} : null} + + {t('operations.time.unpaidBreak')} + + update(index, { UnpaidBreakMinutes: Math.max(0, Number(text) || 0) })} + testID={`operations-entry-break-${index}`} + /> + + + + {t('operations.time.meals')} + update(index, { AgencySuppliedMeals: value })} isDisabled={!rowEditable} /> + + + {t('operations.time.accommodation')} + update(index, { AgencySuppliedAccommodation: value })} isDisabled={!rowEditable} /> + + + update(index, { Notes: text || null })} testID={`operations-entry-notes-${index}`} /> - - - {t('operations.time.meals')} - update(index, { AgencySuppliedMeals: value })} isDisabled={!editable} /> - - - {t('operations.time.accommodation')} - update(index, { AgencySuppliedAccommodation: value })} isDisabled={!editable} /> - - - update(index, { Notes: text || null })} testID={`operations-entry-notes-${index}`} /> - - - {t('operations.time.hours', { hours: entryHours(entry) })} - {editable ? ( - - ) : null} - - {issueFor(index).map((issue, i) => ( - - {t(`operations.issues.${issue.Code}`, { defaultValue: issue.Detail ?? issue.Code })} - - ))} - - ))} + + {t('operations.time.hours', { hours: entryHours(entry) })} + {rowEditable ? ( + + {isCrew && entries.length > 1 ? ( + + ) : null} + + + ) : null} + + {issueFor(index).map((issue, i) => ( + + {issueText(issue)} + + ))} + + ); + })} {issues .filter((issue) => !issue.EntryId || !entries.some((entry, index) => issue.EntryId === String(index) || issue.EntryId === entry.Id)) .map((issue, i) => ( - {t(`operations.issues.${issue.Code}`, { defaultValue: issue.Detail ?? issue.Code })} + {issueText(issue)} + {issue.SubjectId && names[issue.SubjectId] ? ` — ${names[issue.SubjectId]}` : ''} ))} {warnings.map((warning, i) => ( - {t(`operations.issues.${warning.Code}`, { defaultValue: warning.Detail ?? warning.Code })} + {issueText(warning)} + {warning.SubjectId && names[warning.SubjectId] ? ` — ${names[warning.SubjectId]}` : ''} ))} + {Object.keys(perSubject).length > 1 ? ( + + {Object.entries(perSubject).map(([subjectId, hours]) => ( + + {names[subjectId] ?? subjectId} + {t('operations.time.hours', { hours })} + + ))} + + ) : null} {t('operations.time.total', { hours: sumHours(entries) })} {editable ? ( @@ -183,6 +279,43 @@ export const TimeReportEditor = ({ dateKey, report, entries, subjects, issues, w ) : null} + {signable || report.ContractorSignedOn || report.CustomerSignedOn ? ( + + {t('operations.sign.title')} + + {report.ContractorSignedOn ? t('operations.sign.crewBossSigned', { when: String(report.ContractorSignedOn).slice(0, 16).replace('T', ' ') }) : t('operations.sign.crewBossPending')} + + {report.CustomerSignedOn ? t('operations.sign.customerSigned', { name: report.CustomerSignerName ?? '' }) : t('operations.sign.customerPending')} + {signable ? ( + <> + {!report.ContractorSignedOn ? ( + + ) : null} + + + + + + ) : null} + + ) : null} + {canApprove && report.Status === TimeReportStatus.Submitted ? ( + + ) : null} ); }; diff --git a/src/hooks/__tests__/use-set-status-for-call.test.ts b/src/hooks/__tests__/use-set-status-for-call.test.ts new file mode 100644 index 00000000..401bebc8 --- /dev/null +++ b/src/hooks/__tests__/use-set-status-for-call.test.ts @@ -0,0 +1,144 @@ +import { act, renderHook } from '@testing-library/react-native'; + +import { savePersonsStatuses } from '@/api/personnel/personnelStatuses'; +import { saveUnitStatus } from '@/api/units/unitStatuses'; +import { CustomStateDetailType, DestinationEntityType } from '@/lib/destination-helpers'; +import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; +import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; +import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; +import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store'; +import { usePersonnelActionsStore } from '@/stores/dispatch/personnel-actions-store'; +import { useUnitActionsStore } from '@/stores/dispatch/unit-actions-store'; + +import { useSetStatusForCall } from '../use-set-status-for-call'; + +jest.mock('@/api/units/unitStatuses', () => ({ + saveUnitStatus: jest.fn(), +})); + +jest.mock('@/api/personnel/personnelStatuses', () => ({ + savePersonsStatuses: jest.fn(), +})); + +jest.mock('@/api/personnel/personnelStaffing', () => ({ + savePersonsStaffings: jest.fn(), +})); + +const mockSaveUnitStatus = saveUnitStatus as jest.MockedFunction; +const mockSavePersonsStatuses = savePersonsStatuses as jest.MockedFunction; + +const selectedCall = { CallId: '777', Number: '26-777', Name: 'MVA', State: 0 } as CallResultData; +const otherCall = { CallId: '888', Number: '26-888', Name: 'Alarm', State: 0 } as CallResultData; +const engine = { UnitId: 'e1', Name: 'Engine 1', CurrentDestinationId: '' } as UnitInfoResultData; +const medic = { UnitId: 'm1', Name: 'Medic 1', CurrentDestinationId: '' } as UnitInfoResultData; +const firefighter = { UserId: 'u-ff', FirstName: 'Sam', LastName: 'Smith', StatusDestinationId: '' } as PersonnelInfoResultData; +const onScene = { Id: 6, Text: 'On Scene', Detail: CustomStateDetailType.Calls, Note: 0 } as StatusesResultData; +// A status whose Detail lists no destinations — the explicit call context still carries the call +const available = { Id: 0, Text: 'Available', Detail: CustomStateDetailType.None, Note: 0 } as StatusesResultData; + +describe('useSetStatusForCall', () => { + const setSelectedUnitData = jest.fn(); + const setSelectedPersonnelData = jest.fn(); + + const renderHandlers = () => + renderHook(() => + useSetStatusForCall({ + calls: [otherCall, selectedCall], + units: [engine, medic], + personnel: [firefighter], + selectedCallId: selectedCall.CallId, + setSelectedUnitData, + setSelectedPersonnelData, + }) + ); + + beforeEach(() => { + jest.clearAllMocks(); + mockSaveUnitStatus.mockResolvedValue({} as any); + mockSavePersonsStatuses.mockResolvedValue({} as any); + useUnitActionsStore.getState().reset(); + usePersonnelActionsStore.getState().reset(); + useDispatchConsoleStore.setState({ selectedUnitId: null, selectedPersonnelId: null }); + }); + + it('"+" on a unit selects it and opens the unit actions panel with the selected call preset', async () => { + const { result } = renderHandlers(); + + act(() => { + result.current.handleSetUnitStatusForCall('m1'); + }); + + expect(useDispatchConsoleStore.getState().selectedUnitId).toBe('m1'); + expect(useDispatchConsoleStore.getState().selectedPersonnelId).toBeNull(); + expect(setSelectedUnitData).toHaveBeenCalledWith(medic); + expect(setSelectedPersonnelData).toHaveBeenCalledWith(null); + + const unitActions = useUnitActionsStore.getState(); + expect(unitActions.isActionsOpen).toBe(true); + expect(unitActions.selectedUnit).toBe(medic); + expect(unitActions.callContext).toBe(selectedCall); + expect(unitActions.statusDestinationType).toBe('call'); + expect(unitActions.statusSelectedCall).toBe(selectedCall); + + // The dispatcher picks the status and saves through the normal path + await act(async () => { + await useUnitActionsStore.getState().submitStatus({ status: available }); + }); + expect(mockSaveUnitStatus).toHaveBeenCalledWith(expect.objectContaining({ Id: 'm1', Type: '0', RespondingTo: '777', RespondingToType: DestinationEntityType.Call })); + + await act(async () => { + await useUnitActionsStore.getState().submitStatus({ status: onScene }); + }); + expect(mockSaveUnitStatus).toHaveBeenLastCalledWith(expect.objectContaining({ Id: 'm1', Type: '6', RespondingTo: '777', RespondingToType: DestinationEntityType.Call })); + }); + + it('"+" on a person selects them and opens the personnel actions panel with the selected call preset', async () => { + const { result } = renderHandlers(); + + act(() => { + result.current.handleSetPersonnelStatusForCall('u-ff'); + }); + + expect(useDispatchConsoleStore.getState().selectedPersonnelId).toBe('u-ff'); + expect(useDispatchConsoleStore.getState().selectedUnitId).toBeNull(); + expect(setSelectedPersonnelData).toHaveBeenCalledWith(firefighter); + expect(setSelectedUnitData).toHaveBeenCalledWith(null); + + const personnelActions = usePersonnelActionsStore.getState(); + expect(personnelActions.isActionsOpen).toBe(true); + expect(personnelActions.callContext).toBe(selectedCall); + expect(personnelActions.statusSelectedCall).toBe(selectedCall); + + await act(async () => { + await usePersonnelActionsStore.getState().submitStatus({ status: onScene }); + }); + expect(mockSavePersonsStatuses).toHaveBeenCalledWith(expect.objectContaining({ UserIds: ['u-ff'], Type: '6', RespondingTo: '777', RespondingToType: DestinationEntityType.Call })); + }); + + it('re-targets an already-open panel for the same unit with a new session and the call preset', () => { + useUnitActionsStore.getState().openActions(engine); + const session = useUnitActionsStore.getState().actionsSessionId; + const { result } = renderHandlers(); + + act(() => { + result.current.handleSetUnitStatusForCall('e1'); + }); + + expect(useUnitActionsStore.getState().actionsSessionId).toBe(session + 1); + expect(useUnitActionsStore.getState().callContext).toBe(selectedCall); + }); + + it('ignores an unknown unit or person', () => { + const { result } = renderHandlers(); + + act(() => { + result.current.handleSetUnitStatusForCall('missing'); + result.current.handleSetPersonnelStatusForCall('missing'); + }); + + expect(useUnitActionsStore.getState().isActionsOpen).toBe(false); + expect(usePersonnelActionsStore.getState().isActionsOpen).toBe(false); + expect(setSelectedUnitData).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/use-set-status-for-call.ts b/src/hooks/use-set-status-for-call.ts new file mode 100644 index 00000000..380740c4 --- /dev/null +++ b/src/hooks/use-set-status-for-call.ts @@ -0,0 +1,67 @@ +import { useCallback } from 'react'; + +import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; +import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; +import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store'; +import { usePersonnelActionsStore } from '@/stores/dispatch/personnel-actions-store'; +import { useUnitActionsStore } from '@/stores/dispatch/unit-actions-store'; + +interface UseSetStatusForCallOptions { + calls: CallResultData[]; + units: UnitInfoResultData[]; + personnel: PersonnelInfoResultData[]; + selectedCallId: string | null; + // The dashboard keeps the selected unit/person entities in local state and hands them to the actions panel + setSelectedUnitData: (unit: UnitInfoResultData | null) => void; + setSelectedPersonnelData: (person: PersonnelInfoResultData | null) => void; +} + +/** + * Handlers for the dispatch dashboard's "+" set-status-for-call buttons (shown on unit/personnel rows + * while a call is selected). + * + * Selects the unit or person and opens the regular unit/personnel actions panel — the same panel a + * row selection opens — with the destination preset to the selected call. The dispatcher then picks + * the status and saves through the normal API path, which sends `RespondingTo` = the call id and + * `RespondingToType` = Call so the server's call reports link the status change to the call. + */ +export function useSetStatusForCall({ calls, units, personnel, selectedCallId, setSelectedUnitData, setSelectedPersonnelData }: UseSetStatusForCallOptions) { + const setSelectedUnitId = useDispatchConsoleStore((s) => s.setSelectedUnitId); + const setSelectedPersonnelId = useDispatchConsoleStore((s) => s.setSelectedPersonnelId); + const openUnitActions = useUnitActionsStore((s) => s.openActions); + const openPersonnelActions = usePersonnelActionsStore((s) => s.openActions); + + const findSelectedCall = useCallback(() => (selectedCallId ? (calls.find((c) => c.CallId === selectedCallId) ?? null) : null), [calls, selectedCallId]); + + const handleSetUnitStatusForCall = useCallback( + (unitId: string) => { + const unit = units.find((u) => u.UnitId === unitId); + if (!unit) return; + + // Units and personnel are mutually exclusive selections on the dashboard + setSelectedPersonnelId(null); + setSelectedPersonnelData(null); + setSelectedUnitId(unitId); + setSelectedUnitData(unit); + openUnitActions(unit, { callContext: findSelectedCall() }); + }, + [units, findSelectedCall, setSelectedPersonnelId, setSelectedPersonnelData, setSelectedUnitId, setSelectedUnitData, openUnitActions] + ); + + const handleSetPersonnelStatusForCall = useCallback( + (personnelId: string) => { + const person = personnel.find((p) => p.UserId === personnelId); + if (!person) return; + + setSelectedUnitId(null); + setSelectedUnitData(null); + setSelectedPersonnelId(personnelId); + setSelectedPersonnelData(person); + openPersonnelActions(person, { callContext: findSelectedCall() }); + }, + [personnel, findSelectedCall, setSelectedUnitId, setSelectedUnitData, setSelectedPersonnelId, setSelectedPersonnelData, openPersonnelActions] + ); + + return { handleSetUnitStatusForCall, handleSetPersonnelStatusForCall }; +} diff --git a/src/lib/__tests__/activity-link.test.ts b/src/lib/__tests__/activity-link.test.ts new file mode 100644 index 00000000..d3dd54c0 --- /dev/null +++ b/src/lib/__tests__/activity-link.test.ts @@ -0,0 +1,42 @@ +import { getActivityLinkKind, getActivityLinkKinds, StatusDestinationSources } from '../activity-link'; + +describe('getActivityLinkKind', () => { + it.each([ + [StatusDestinationSources.CarryForward, 'auto'], + [StatusDestinationSources.Dispatch, 'auto'], + [StatusDestinationSources.Unit, 'auto'], + [StatusDestinationSources.Inferred, 'inferred'], + ])('maps source %p to %p', (source, expected) => { + expect(getActivityLinkKind(source)).toBe(expected); + }); + + it.each([[StatusDestinationSources.Explicit], [null], [undefined], [0], [6], [-1]])('returns null for source %p', (source) => { + expect(getActivityLinkKind(source)).toBeNull(); + }); + + it('matches the server StatusDestinationSources values', () => { + expect(StatusDestinationSources.Explicit).toBe(1); + expect(StatusDestinationSources.CarryForward).toBe(2); + expect(StatusDestinationSources.Dispatch).toBe(3); + expect(StatusDestinationSources.Unit).toBe(4); + expect(StatusDestinationSources.Inferred).toBe(5); + }); +}); + +describe('getActivityLinkKinds', () => { + it('returns nothing for an empty or missing list', () => { + expect(getActivityLinkKinds(undefined)).toEqual([]); + expect(getActivityLinkKinds(null)).toEqual([]); + expect(getActivityLinkKinds([])).toEqual([]); + }); + + it('returns nothing when no entry is marked', () => { + expect(getActivityLinkKinds([{ DestinationSource: 1 }, { DestinationSource: null }, {}])).toEqual([]); + }); + + it('returns each present kind once, auto before inferred', () => { + expect(getActivityLinkKinds([{ DestinationSource: 5 }, { DestinationSource: 2 }, { DestinationSource: 4 }, { DestinationSource: 5 }])).toEqual(['auto', 'inferred']); + expect(getActivityLinkKinds([{ DestinationSource: 3 }, { DestinationSource: 1 }])).toEqual(['auto']); + expect(getActivityLinkKinds([{ DestinationSource: 5 }])).toEqual(['inferred']); + }); +}); diff --git a/src/lib/__tests__/destination-helpers.test.ts b/src/lib/__tests__/destination-helpers.test.ts index 2b0eeee6..3ff439c8 100644 --- a/src/lib/__tests__/destination-helpers.test.ts +++ b/src/lib/__tests__/destination-helpers.test.ts @@ -8,10 +8,14 @@ import { getDestinationCapabilities, getDestinationSelectionTypeFromValue, getDestinationSelectionTypeValue, + getEffectiveDestinationType, getEnabledDestinationTabs, getSelectedDestinationId, + getStatusDestinationCapabilities, + getStatusDestinationPayload, isCallMarker, isPoiDestinationType, + resolveDefaultDestinationCall, } from '../destination-helpers'; describe('destination-helpers', () => { @@ -53,4 +57,66 @@ describe('destination-helpers', () => { expect(isCallMarker(MapMarkerEntityType.Call)).toBe(true); expect(isCallMarker(undefined, 'call')).toBe(true); }); + + describe('status destination filtering', () => { + const call = { CallId: 'call-7', Number: '7', Name: 'Structure Fire', State: 0 } as any; + const station = { GroupId: 'station-3', Name: 'Station 3' } as any; + const callSelection = { selectedDestinationType: 'call' as const, selectedCall: call, selectedStation: null, selectedPoi: null }; + const stationSelection = { selectedDestinationType: 'station' as const, selectedCall: null, selectedStation: station, selectedPoi: null }; + + it('widens capabilities to calls only for an explicit call context', () => { + expect(getStatusDestinationCapabilities(CustomStateDetailType.None)).toEqual({ showCalls: false, showStations: false, showPois: false, supportsDestination: false }); + expect(getStatusDestinationCapabilities(CustomStateDetailType.None, true)).toEqual({ showCalls: true, showStations: false, showPois: false, supportsDestination: true }); + expect(getStatusDestinationCapabilities(CustomStateDetailType.Stations, true)).toEqual({ showCalls: true, showStations: true, showPois: false, supportsDestination: true }); + }); + + it('sends a call destination as RespondingTo = CallId / RespondingToType = Call when the status supports calls', () => { + expect(getStatusDestinationPayload(callSelection, CustomStateDetailType.Calls)).toEqual({ respondingTo: 'call-7', respondingToType: DestinationEntityType.Call }); + expect(getStatusDestinationPayload(callSelection, CustomStateDetailType.CallsAndStations)).toEqual({ respondingTo: 'call-7', respondingToType: DestinationEntityType.Call }); + }); + + it('drops a leftover destination the status does not support', () => { + expect(getEffectiveDestinationType(callSelection, CustomStateDetailType.Stations)).toBe('none'); + expect(getStatusDestinationPayload(callSelection, CustomStateDetailType.Stations)).toEqual({ respondingTo: '', respondingToType: null }); + expect(getStatusDestinationPayload(callSelection, CustomStateDetailType.None)).toEqual({ respondingTo: '', respondingToType: null }); + expect(getStatusDestinationPayload(stationSelection, CustomStateDetailType.Calls)).toEqual({ respondingTo: '', respondingToType: null }); + expect(getStatusDestinationPayload(stationSelection, CustomStateDetailType.Stations)).toEqual({ respondingTo: 'station-3', respondingToType: DestinationEntityType.Station }); + }); + + it('keeps the call for an explicit call context even when the status Detail does not list calls', () => { + expect(getStatusDestinationPayload(callSelection, CustomStateDetailType.None, true)).toEqual({ respondingTo: 'call-7', respondingToType: DestinationEntityType.Call }); + expect(getStatusDestinationPayload(callSelection, CustomStateDetailType.Stations, true)).toEqual({ respondingTo: 'call-7', respondingToType: DestinationEntityType.Call }); + // The widening is for calls only — a station still needs a status that supports stations + expect(getStatusDestinationPayload(stationSelection, CustomStateDetailType.None, true)).toEqual({ respondingTo: '', respondingToType: null }); + }); + + it('never reports a destination for an empty selection', () => { + expect(getEffectiveDestinationType({ selectedDestinationType: 'call', selectedCall: null, selectedStation: null, selectedPoi: null }, CustomStateDetailType.Calls)).toBe('none'); + expect(getEffectiveDestinationType({ selectedDestinationType: 'none', selectedCall: null, selectedStation: null, selectedPoi: null }, CustomStateDetailType.Calls, true)).toBe('none'); + }); + }); + + describe('resolveDefaultDestinationCall', () => { + const callA = { CallId: 'A', State: 0 } as any; + const callB = { CallId: 'B', State: 0 } as any; + const contextCall = { CallId: 'CTX', State: 0 } as any; + + it('prefers the explicit call context over everything else', () => { + expect(resolveDefaultDestinationCall({ callContext: contextCall, currentDestinationId: 'A', selectedCallId: 'B', activeCalls: [callA, callB] })).toBe(contextCall); + }); + + it("falls back to the unit's/person's current destination when it is an active call", () => { + expect(resolveDefaultDestinationCall({ currentDestinationId: 'A', selectedCallId: 'B', activeCalls: [callA, callB] })).toBe(callA); + }); + + it("uses the console's selected call when the current destination is not an active call", () => { + expect(resolveDefaultDestinationCall({ currentDestinationId: 'closed-or-station', selectedCallId: 'B', activeCalls: [callA, callB] })).toBe(callB); + expect(resolveDefaultDestinationCall({ currentDestinationId: '', selectedCallId: 'B', activeCalls: [callA, callB] })).toBe(callB); + }); + + it('returns null when neither the current destination nor the selected call is active', () => { + expect(resolveDefaultDestinationCall({ currentDestinationId: 'X', selectedCallId: 'Y', activeCalls: [callA, callB] })).toBeNull(); + expect(resolveDefaultDestinationCall({ activeCalls: [] })).toBeNull(); + }); + }); }); diff --git a/src/lib/__tests__/unit-status-helpers.test.ts b/src/lib/__tests__/unit-status-helpers.test.ts new file mode 100644 index 00000000..dd89ff2b --- /dev/null +++ b/src/lib/__tests__/unit-status-helpers.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from '@jest/globals'; + +import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; +import { type UnitTypeStatusResultData } from '@/models/v4/statuses/unitTypeStatusResultData'; + +import { resolveUnitStatusOptions } from '../unit-status-helpers'; + +const status = (Id: number, Text: string, Detail: number): StatusesResultData => ({ Id, Type: 3, StateId: 0, Text, BColor: '', Color: '', Gps: false, Note: 0, Detail }) as StatusesResultData; + +// The department default group ("0") as served by /Statuses/GetAllUnitStatuses, with real Detail types +const defaultGroup: UnitTypeStatusResultData = { + UnitType: '0', + StatusId: '', + Statuses: [status(0, 'Available', 1), status(3, 'Responding', 2), status(6, 'On Scene', 2), status(8, 'Staging', 2)], +}; + +const engineGroup: UnitTypeStatusResultData = { + UnitType: 'Engine', + StatusId: '42', + Statuses: [status(100, 'Engine Responding', 2), status(101, 'Engine In Quarters', 1)], +}; + +// What an older server returns from /Dispatch/GetSetUnitStatusData for a unit without a custom set: all Detail 0 +const legacyServerList = [status(0, 'Available', 0), status(3, 'Responding', 0), status(6, 'On Scene', 0)]; + +describe('resolveUnitStatusOptions', () => { + it('returns the custom status set matched by CustomStatusSetId', () => { + expect(resolveUnitStatusOptions({ CustomStatusSetId: '42', Type: 'Something Else' }, [defaultGroup, engineGroup], legacyServerList)).toBe(engineGroup.Statuses); + }); + + it('returns the custom status set matched by unit type name', () => { + expect(resolveUnitStatusOptions({ CustomStatusSetId: '', Type: 'Engine' }, [defaultGroup, engineGroup], legacyServerList)).toBe(engineGroup.Statuses); + }); + + it('prefers the department default group over the per-unit server list for a unit without a custom set', () => { + const options = resolveUnitStatusOptions({ CustomStatusSetId: '', Type: 'Ladder' }, [defaultGroup, engineGroup], legacyServerList); + + expect(options).toBe(defaultGroup.Statuses); + // Responding / On Scene / Staging accept a call destination + expect(options.filter((s) => s.Detail === 2).map((s) => s.Text)).toEqual(['Responding', 'On Scene', 'Staging']); + }); + + it('prefers the default group when the unit has no type at all', () => { + expect(resolveUnitStatusOptions({ CustomStatusSetId: '', Type: '' }, [defaultGroup], legacyServerList)).toBe(defaultGroup.Statuses); + }); + + it('falls back to the per-unit server list only when there is no default group', () => { + expect(resolveUnitStatusOptions({ CustomStatusSetId: '', Type: 'Ladder' }, [engineGroup], legacyServerList)).toBe(legacyServerList); + expect(resolveUnitStatusOptions({ CustomStatusSetId: '', Type: 'Ladder' }, undefined, legacyServerList)).toBe(legacyServerList); + expect(resolveUnitStatusOptions({ CustomStatusSetId: '', Type: 'Ladder' }, [{ ...defaultGroup, Statuses: [] }], legacyServerList)).toBe(legacyServerList); + }); + + it('returns an empty list when nothing is available', () => { + expect(resolveUnitStatusOptions(null, [], [])).toEqual([]); + }); +}); diff --git a/src/lib/activity-link.ts b/src/lib/activity-link.ts new file mode 100644 index 00000000..c7d1fa72 --- /dev/null +++ b/src/lib/activity-link.ts @@ -0,0 +1,46 @@ +import { type DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData'; + +/** + * Server `StatusDestinationSources`: how a status entry in a call's `Activity` came to be tied to the call. + * Carried on `DispatchedEventResultData.DestinationSource`. + */ +export enum StatusDestinationSources { + /** The sender chose the call. */ + Explicit = 1, + /** Sent without a call; linked from the unit's/person's previous status. */ + CarryForward = 2, + /** Sent without a call; linked from the sender's one open dispatch. */ + Dispatch = 3, + /** Sent without a call by a person; linked from the unit they rode. */ + Unit = 4, + /** Set with no destination by a resource dispatched to the call while working it (derived at read time, not stored). */ + Inferred = 5, +} + +/** The marker a status entry carries: `auto` = auto-linked by the server, `inferred` = attributed at read time. */ +export type ActivityLinkKind = 'auto' | 'inferred'; + +/** Maps a `DestinationSource` to its marker; `null` for explicit entries, older rows and non-status entries. */ +export function getActivityLinkKind(source: number | null | undefined): ActivityLinkKind | null { + switch (source) { + case StatusDestinationSources.CarryForward: + case StatusDestinationSources.Dispatch: + case StatusDestinationSources.Unit: + return 'auto'; + case StatusDestinationSources.Inferred: + return 'inferred'; + default: + return null; + } +} + +/** The distinct markers present in an activity list, in display order (`auto` before `inferred`). */ +export function getActivityLinkKinds(activity: readonly Pick[] | null | undefined): ActivityLinkKind[] { + if (!activity || activity.length === 0) return []; + const present = new Set(); + for (const item of activity) { + const kind = getActivityLinkKind(item?.DestinationSource); + if (kind) present.add(kind); + } + return (['auto', 'inferred'] as const).filter((kind) => present.has(kind)); +} diff --git a/src/lib/contacts/__tests__/format.test.ts b/src/lib/contacts/__tests__/format.test.ts new file mode 100644 index 00000000..2e3cd00d --- /dev/null +++ b/src/lib/contacts/__tests__/format.test.ts @@ -0,0 +1,27 @@ +import { htmlToText, mapsUrls, parseCoordinates, withoutRedacted } from '@/lib/contacts/format'; +import { type ContactResultData, ContactType } from '@/models/v4/contacts/contactResultData'; + +it('turns web-editor rich text into readable plain text', () => { + expect(htmlToText('

Knox box at north door

Gate & code

  • Hydrant
')).toBe('Knox box at north door\nGate & code\n• Hydrant'); + expect(htmlToText(null)).toBe(''); +}); + +it('parses coordinates and builds a maps link for an address or a point', () => { + expect(parseCoordinates('39.7392, -104.9903')).toEqual({ latitude: 39.7392, longitude: -104.9903 }); + expect(parseCoordinates('not a place')).toBeNull(); + expect(parseCoordinates('95, 10')).toBeNull(); + // The platform app link differs (maps: on iOS, geo: on Android); both carry the point or the encoded address. + expect(mapsUrls('39.7392,-104.9903').app).toMatch(/39\.7392,-104\.9903.*q=39\.7392%2C-104\.9903$/); + expect(mapsUrls('1 Main St, Denver').app).toMatch(/0,0\?q=1%20Main%20St%2C%20Denver$/); + expect(mapsUrls('1 Main St').web).toBe('https://maps.google.com/?q=1%20Main%20St'); +}); + +it('removes withheld values and names the fields so the sheet never shows or dials the sentinel', () => { + const contact = { ContactId: 'c-1', ContactType: ContactType.Person, FirstName: 'Ana', CellPhoneNumber: 'REDACTED', Email: 'REDACTED', IsDeleted: false } as unknown as ContactResultData; + const shown = withoutRedacted(contact); + expect(shown.CellPhoneNumber).toBeUndefined(); + expect(shown.Email).toBeUndefined(); + expect(shown.FirstName).toBe('Ana'); + expect(shown.WithheldFields).toEqual(['CellPhoneNumber', 'Email']); + expect(contact.CellPhoneNumber).toBe('REDACTED'); +}); diff --git a/src/lib/contacts/format.ts b/src/lib/contacts/format.ts new file mode 100644 index 00000000..08a49510 --- /dev/null +++ b/src/lib/contacts/format.ts @@ -0,0 +1,64 @@ +import { Platform } from 'react-native'; + +import { type ContactResultData } from '@/models/v4/contacts/contactResultData'; + +// Pure presentation helpers for a contact (the shared detail sheet in every field app). Nothing here decides +// what a person may see: withheld values arrive redacted from the server and are shown as such by the sheet. + +const ENTITIES: Record = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'", ' ': ' ' }; + +/** Rich text from the web editor as plain readable text: line breaks kept, markup and scripts dropped. */ +export const htmlToText = (html: string | null | undefined): string => { + if (!html) return ''; + return html + .replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, '') + .replace(//gi, '\n') + .replace(/<\/(p|div|li|h[1-6]|tr)>/gi, '\n') + .replace(/]*>/gi, '• ') + .replace(/<[^>]+>/g, '') + .replace(/&(amp|lt|gt|quot|#39|apos|nbsp);/g, (entity) => ENTITIES[entity] ?? entity) + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +}; + +/** "lat,lng" (or "lat lng") as numbers, when it parses. */ +export const parseCoordinates = (value: string | null | undefined): { latitude: number; longitude: number } | null => { + const match = /^\s*(-?\d+(?:\.\d+)?)\s*[, ]\s*(-?\d+(?:\.\d+)?)\s*$/.exec(value ?? ''); + if (!match) return null; + const latitude = Number(match[1]); + const longitude = Number(match[2]); + return Math.abs(latitude) <= 90 && Math.abs(longitude) <= 180 ? { latitude, longitude } : null; +}; + +/** The platform maps app for an address or a coordinate pair, with a web map as the fallback. */ +export const mapsUrls = (query: string) => { + const coordinates = parseCoordinates(query); + const encoded = encodeURIComponent(coordinates ? `${coordinates.latitude},${coordinates.longitude}` : query); + const web = `https://maps.google.com/?q=${encoded}`; + const app = Platform.select({ + ios: coordinates ? `maps:?ll=${coordinates.latitude},${coordinates.longitude}&q=${encoded}` : `maps:0,0?q=${encoded}`, + android: coordinates ? `geo:${coordinates.latitude},${coordinates.longitude}?q=${encoded}` : `geo:0,0?q=${encoded}`, + default: web, + }); + return { app, web }; +}; + +const REDACTED = 'REDACTED'; + +/** + * The contact with every withheld value removed, plus the names of the fields that were withheld. The detail + * sheet then never shows the wire sentinel as data or dials "tel:REDACTED"; it says what is protected and + * offers the reveal instead. The server's RedactedFields list marks catalog fields; the sentinel is the fallback. + */ +export const withoutRedacted = (contact: T): T & { WithheldFields: string[] } => { + const withheld: string[] = []; + const copy = { ...contact } as unknown as Record; + for (const [key, value] of Object.entries(contact)) { + if (value === REDACTED) { + copy[key] = undefined; + withheld.push(key); + } + } + return { ...(copy as T), WithheldFields: withheld }; +}; diff --git a/src/lib/destination-helpers.ts b/src/lib/destination-helpers.ts index 81ee0154..dea39eae 100644 --- a/src/lib/destination-helpers.ts +++ b/src/lib/destination-helpers.ts @@ -168,3 +168,97 @@ export const getSelectedDestinationId = ({ selectedDestinationType, selectedCall return ''; } }; + +/** + * Destination capabilities for a status change. Normally these come straight from the status's + * `Detail`, but a status set from an explicit call context (the dashboard "+" set-status-for-call + * action) may always carry a call destination, whatever the status's `Detail` lists — that call is + * how the server's call reports attribute the status change to the call. + */ +export const getStatusDestinationCapabilities = (detail?: number | null, hasCallContext = false): DestinationCapabilities => { + const capabilities = getDestinationCapabilities(detail); + if (!hasCallContext || capabilities.showCalls) { + return capabilities; + } + + return { ...capabilities, showCalls: true, supportsDestination: true }; +}; + +/** + * Narrows a destination selection to what the chosen status accepts. A destination picked (or + * carried over) for an earlier status is dropped to 'none' when the new status's `Detail` does not + * support that destination type, so a leftover call/station/POI is never sent with an unrelated status. + */ +export const getEffectiveDestinationType = (selection: DestinationSelectionState, detail?: number | null, hasCallContext = false): DestinationSelectionType => { + const capabilities = getStatusDestinationCapabilities(detail, hasCallContext); + + switch (selection.selectedDestinationType) { + case 'call': + return capabilities.showCalls && selection.selectedCall ? 'call' : 'none'; + case 'station': + return capabilities.showStations && selection.selectedStation ? 'station' : 'none'; + case 'poi': + return capabilities.showPois && selection.selectedPoi ? 'poi' : 'none'; + default: + return 'none'; + } +}; + +export interface StatusDestinationPayload { + respondingTo: string; + respondingToType: DestinationEntityType | null; +} + +/** + * The `RespondingTo` / `RespondingToType` pair to send with a status change (see + * {@link getEffectiveDestinationType}). Returns an empty destination when the status does not + * support the selected destination type. + */ +export const getStatusDestinationPayload = (selection: DestinationSelectionState, detail?: number | null, hasCallContext = false): StatusDestinationPayload => { + const effectiveType = getEffectiveDestinationType(selection, detail, hasCallContext); + const respondingTo = getSelectedDestinationId({ ...selection, selectedDestinationType: effectiveType }); + + if (!respondingTo) { + return { respondingTo: '', respondingToType: null }; + } + + return { respondingTo, respondingToType: getDestinationSelectionTypeValue(effectiveType) }; +}; + +export interface DefaultDestinationCallInput { + /** Explicit call context (the dashboard "+" set-status-for-call action). Always wins. */ + callContext?: CallResultData | null; + /** The unit's `CurrentDestinationId` / the person's `StatusDestinationId`. */ + currentDestinationId?: string | null; + /** The call currently selected on the dispatch console. */ + selectedCallId?: string | null; + /** Active calls only — a closed or cancelled call is never a default destination. */ + activeCalls: CallResultData[]; +} + +/** + * Picks the call a status change for a unit/person should default to: + * (a) the explicit call context; (b) the unit's/person's current destination when it is a call that + * is still active; (c) the console's selected call when it is active; otherwise none. + */ +export const resolveDefaultDestinationCall = ({ callContext, currentDestinationId, selectedCallId, activeCalls }: DefaultDestinationCallInput): CallResultData | null => { + if (callContext) { + return callContext; + } + + if (currentDestinationId) { + const currentCall = activeCalls.find((call) => call.CallId === currentDestinationId); + if (currentCall) { + return currentCall; + } + } + + if (selectedCallId) { + const selectedCall = activeCalls.find((call) => call.CallId === selectedCallId); + if (selectedCall) { + return selectedCall; + } + } + + return null; +}; diff --git a/src/lib/media/photo.ts b/src/lib/media/photo.ts new file mode 100644 index 00000000..86107158 --- /dev/null +++ b/src/lib/media/photo.ts @@ -0,0 +1,47 @@ +import * as FileSystem from 'expo-file-system/legacy'; +import { manipulateAsync, SaveFormat } from 'expo-image-manipulator'; +import * as ImagePicker from 'expo-image-picker'; + +// One photo from the camera or the library, normalised for upload: HEIC and other camera formats become +// JPEG, the long side is bounded, and metadata (GPS, device) is dropped by the re-encode. Receipts, work +// order photos and certificate scans all go through here. Cache copies are removed; a camera-roll original +// never is. + +export interface CapturedPhoto { + base64: string; + uri: string; + name: string; + contentType: 'image/jpeg'; + width: number; + height: number; +} + +export class PhotoPermissionError extends Error { + constructor() { + super('denied'); + } +} + +const MAX_EDGE = 2048; + +export const capturePhoto = async (source: 'camera' | 'library', name = `photo-${Date.now()}.jpg`): Promise => { + const permission = await (source === 'camera' ? ImagePicker.requestCameraPermissionsAsync() : ImagePicker.requestMediaLibraryPermissionsAsync()); + if (!permission.granted) throw new PhotoPermissionError(); + const result = await (source === 'camera' ? ImagePicker.launchCameraAsync({ quality: 0.8, exif: false }) : ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 0.8, exif: false })); + if (result.canceled || !result.assets?.[0]) return null; + const asset = result.assets[0]; + let converted: { uri: string; base64?: string; width: number; height: number } | null = null; + try { + const resize = asset.width >= asset.height ? { width: Math.min(asset.width || MAX_EDGE, MAX_EDGE) } : { height: Math.min(asset.height || MAX_EDGE, MAX_EDGE) }; + converted = await manipulateAsync(asset.uri, [{ resize }], { format: SaveFormat.JPEG, compress: 0.8, base64: true }); + if (!converted.base64) return null; + return { base64: converted.base64, uri: converted.uri, name: name.toLowerCase().endsWith('.jpg') ? name : `${name}.jpg`, contentType: 'image/jpeg', width: converted.width, height: converted.height }; + } finally { + if (FileSystem.cacheDirectory && asset.uri.startsWith(FileSystem.cacheDirectory) && asset.uri !== converted?.uri) await FileSystem.deleteAsync(asset.uri, { idempotent: true }).catch(() => undefined); + } +}; + +/** Removes an app cache copy made by {@link capturePhoto} once it has been uploaded. */ +export const discardPhoto = async (photo: CapturedPhoto | null | undefined) => { + if (photo?.uri && FileSystem.cacheDirectory && photo.uri.startsWith(FileSystem.cacheDirectory)) await FileSystem.deleteAsync(photo.uri, { idempotent: true }).catch(() => undefined); +}; diff --git a/src/lib/notifications/__tests__/inbox-reference.test.ts b/src/lib/notifications/__tests__/inbox-reference.test.ts new file mode 100644 index 00000000..8084c6be --- /dev/null +++ b/src/lib/notifications/__tests__/inbox-reference.test.ts @@ -0,0 +1,34 @@ +import { referenceFromEventCode, referenceHref } from '../inbox-reference'; + +describe('inbox references', () => { + it('reads call dispatch codes, which carry no separator', () => { + expect(referenceFromEventCode('C1234')).toEqual({ referenceType: 'call', referenceId: '1234' }); + expect(referenceFromEventCode('c:77')).toEqual({ referenceType: 'call', referenceId: '77' }); + }); + + it('does not read a communication-test token as a call', () => { + expect(referenceFromEventCode('CT:9f2c')).toBeUndefined(); + }); + + it('reads direct and group chat codes', () => { + expect(referenceFromEventCode('t:9a2b')).toEqual({ referenceType: 'chat', referenceId: '9a2b' }); + expect(referenceFromEventCode('G:7f1c')).toEqual({ referenceType: 'chat', referenceId: '7f1c' }); + }); + + it('refuses ids that could steer the router and codes it has no screen for', () => { + expect(referenceFromEventCode('g:../call/9')).toBeUndefined(); + expect(referenceFromEventCode('t:a?x=1')).toBeUndefined(); + expect(referenceFromEventCode('NWO:0b7c3e52-2f4a-4d0e-9a57-1f7a0c9d6e11')).toBeUndefined(); + expect(referenceFromEventCode('M5678')).toBeUndefined(); + expect(referenceFromEventCode('')).toBeUndefined(); + expect(referenceFromEventCode(undefined)).toBeUndefined(); + expect(referenceFromEventCode(42)).toBeUndefined(); + }); + + it('builds the route for each reference and refuses unsafe ids', () => { + expect(referenceHref('call', '1234')).toEqual({ pathname: '/call/[id]', params: { id: '1234' } }); + expect(referenceHref('chat', '7f1c')).toEqual({ pathname: '/chat/[channelId]', params: { channelId: '7f1c' } }); + expect(referenceHref('chat', '../settings')).toBeNull(); + expect(referenceHref('note', 'n-1')).toBeNull(); + }); +}); diff --git a/src/lib/notifications/inbox-reference.ts b/src/lib/notifications/inbox-reference.ts new file mode 100644 index 00000000..abb47c40 --- /dev/null +++ b/src/lib/notifications/inbox-reference.ts @@ -0,0 +1,32 @@ +import type { Href } from 'expo-router'; + +import { isSafeRouteId, parseNotificationData } from '@/stores/push-notification/store'; + +export type InboxReferenceType = 'call' | 'chat'; + +export interface InboxReference { + referenceType: InboxReferenceType; + referenceId: string; +} + +/** + * The item a Novu inbox notification links to, read from the push event code the Novu bridge copies + * into the in-app `data`: "C{callId}" for call dispatches, "t:{channelId}" / "g:{channelId}" for chat. + * Ids that could steer the router elsewhere are refused, as the push handlers do. + */ +export const referenceFromEventCode = (eventCode: unknown): InboxReference | undefined => { + if (typeof eventCode !== 'string') return undefined; + const call = /^C:?(\d+)$/i.exec(eventCode); + if (call) return { referenceType: 'call', referenceId: call[1] }; + const parsed = parseNotificationData({ eventCode }); + if ((parsed.type === 'chat' || parsed.type === 'group-chat') && isSafeRouteId(parsed.id)) return { referenceType: 'chat', referenceId: parsed.id }; + return undefined; +}; + +/** The screen an inbox reference opens, or null when this app has none for it. */ +export const referenceHref = (referenceType: string, referenceId: string): Href | null => { + if (!isSafeRouteId(referenceId)) return null; + if (referenceType === 'call') return { pathname: '/call/[id]', params: { id: referenceId } }; + if (referenceType === 'chat') return { pathname: '/chat/[channelId]', params: { channelId: referenceId } }; + return null; +}; diff --git a/src/lib/operations/__tests__/time.test.ts b/src/lib/operations/__tests__/time.test.ts index e6670336..a980db59 100644 --- a/src/lib/operations/__tests__/time.test.ts +++ b/src/lib/operations/__tests__/time.test.ts @@ -1,7 +1,29 @@ -import { combineDateTime, entryHours, isClockTime, newEntry, operationsError, reportForDate, rosterSubjects, sumHours, timeOf, validateEntries } from '@/lib/operations/time'; -import type { Deployment, TimeEntry } from '@/models/v4/operations'; +import { + applyToCrew, + availableScopes, + combineDateTime, + coveringReport, + defaultScope, + endLocalFor, + entryHours, + hoursBySubject, + isClockTime, + isReportEditable, + newEntry, + operationsError, + reportForScope, + scopeKey, + scopeSubjects, + shiftDateKey, + sumHours, + timeOf, + validateEntries, +} from '@/lib/operations/time'; +import type { Deployment, TimeEntry, TimeReport } from '@/models/v4/operations'; -const deployment = (): Deployment => ({ +// Engine 41 (me as crew boss, a crew member, a released member, a pump), Engine 42 (another crew, a saw), +// a single resource with no unit. TimeAccess is what the server answers for "me". +const deployment = (access: Partial> = {}): Deployment => ({ Id: 'dep-1', Name: 'LNU Lightning Complex', Status: 2, @@ -16,29 +38,39 @@ const deployment = (): Deployment => ({ { Id: 'dp-2', UserId: 'crew', Name: 'Crew', DeploymentUnitId: 'du-1', IsActive: true, CallSign: 'FF' }, { Id: 'dp-3', UserId: 'other', Name: 'Other', DeploymentUnitId: 'du-2', IsActive: true }, { Id: 'dp-4', UserId: 'gone', Name: 'Gone', DeploymentUnitId: 'du-1', IsActive: false }, + { Id: 'dp-5', UserId: 'solo', Name: 'Solo', DeploymentUnitId: null, IsActive: true }, ], - Equipment: [{ Id: 'de-1', DeploymentUnitId: 'du-2', Name: 'Saw', IsActive: true }], + Equipment: [ + { Id: 'de-1', DeploymentUnitId: 'du-1', Name: 'Pump', IsActive: true }, + { Id: 'de-2', DeploymentUnitId: 'du-2', Name: 'Saw', IsActive: true }, + ], + TimeAccess: { CanManage: false, CanApprove: false, PersonnelId: 'dp-1', CrewUnitIds: ['du-1'], WritableSubjectIds: ['dp-1', 'du-1', 'dp-2', 'dp-4', 'de-1'], TimeZone: 'Pacific Standard Time', ...access }, }); const entry = (patch: Partial): TimeEntry => ({ ...newEntry({ type: 0, id: 'dp-1', label: 'Me' }, '2026-09-19', 0), ...patch }); +const report = (patch: Partial): TimeReport => ({ Id: 'r-1', DeploymentId: 'dep-1', ReportNumber: 1, ReportDate: '2026-09-19T00:00:00', Scope: 1, DeploymentUnitId: 'du-1', CanAct: true, Status: 0, Entries: [], ...patch }); -it('computes hours from deployment-local wall clock minus unpaid breaks', () => { - expect(timeOf('2026-09-19T07:30:00')).toBe('07:30'); +it('computes hours from department-local wall clock minus unpaid breaks, across midnight too', () => { + expect(timeOf('2026-09-19T07:30')).toBe('07:30'); expect(isClockTime('7:30')).toBe(false); expect(isClockTime('23:59')).toBe(true); - expect(combineDateTime('2026-09-19', '07:30')).toBe('2026-09-19T07:30:00'); - expect(entryHours(entry({ StartTime: '2026-09-19T08:00:00', EndTime: '2026-09-19T18:00:00', UnpaidBreakMinutes: 30 }))).toBe(9.5); - expect(entryHours(entry({ StartTime: '2026-09-19T18:00:00', EndTime: '2026-09-19T08:00:00' }))).toBe(0); - expect(sumHours([entry({ StartTime: '2026-09-19T08:00:00', EndTime: '2026-09-19T12:15:00' }), entry({ StartTime: '2026-09-19T13:00:00', EndTime: '2026-09-19T14:00:00' })])).toBe(5.25); + expect(combineDateTime('2026-09-19', '07:30')).toBe('2026-09-19T07:30'); + expect(shiftDateKey('2026-12-31', 1)).toBe('2027-01-01'); + expect(endLocalFor('2026-09-19T20:00', '06:00')).toBe('2026-09-20T06:00'); + expect(endLocalFor('2026-09-19T08:00', '18:00')).toBe('2026-09-19T18:00'); + expect(entryHours(entry({ StartLocal: '2026-09-19T08:00', EndLocal: '2026-09-19T18:00', UnpaidBreakMinutes: 30 }))).toBe(9.5); + expect(entryHours(entry({ StartLocal: '2026-09-19T20:00', EndLocal: '2026-09-20T06:00' }))).toBe(10); + expect(entryHours(entry({ StartLocal: '2026-09-19T18:00', EndLocal: '2026-09-19T08:00' }))).toBe(0); + expect(sumHours([entry({ StartLocal: '2026-09-19T08:00', EndLocal: '2026-09-19T12:15' }), entry({ StartLocal: '2026-09-19T13:00', EndLocal: '2026-09-19T14:00' })])).toBe(5.25); }); it('flags missing subjects, inverted times and overlapping entries for the same subject only', () => { const issues = validateEntries([ entry({ DeploymentPersonnelId: null }), - entry({ StartTime: '2026-09-19T18:00:00', EndTime: '2026-09-19T08:00:00' }), - entry({ StartTime: '2026-09-19T08:00:00', EndTime: '2026-09-19T12:00:00' }), - entry({ StartTime: '2026-09-19T11:00:00', EndTime: '2026-09-19T13:00:00' }), - entry({ DeploymentPersonnelId: 'dp-2', StartTime: '2026-09-19T11:00:00', EndTime: '2026-09-19T13:00:00' }), + entry({ StartLocal: '2026-09-19T18:00', EndLocal: '2026-09-19T08:00' }), + entry({ StartLocal: '2026-09-19T08:00', EndLocal: '2026-09-19T12:00' }), + entry({ StartLocal: '2026-09-19T11:00', EndLocal: '2026-09-19T13:00' }), + entry({ DeploymentPersonnelId: 'dp-2', StartLocal: '2026-09-19T11:00', EndLocal: '2026-09-19T13:00' }), ]); expect(issues).toEqual([ { code: 'missing_subject', index: 0 }, @@ -47,23 +79,62 @@ it('flags missing subjects, inverted times and overlapping entries for the same ]); }); -it('scopes the roster picker to the person, their active unit or the whole deployment for a manager', () => { - const all = rosterSubjects(deployment(), { userId: 'me', activeUnitId: null, manager: true }); - expect(all.map((subject) => subject.id)).toEqual(['dp-1', 'dp-2', 'dp-3', 'du-1', 'du-2', 'de-1']); - expect(all.find((subject) => subject.id === 'dp-2')?.label).toBe('Crew (FF)'); - expect(rosterSubjects(deployment(), { userId: 'me', activeUnitId: '12', manager: false }).map((subject) => subject.id)).toEqual(['dp-1', 'dp-2', 'du-1']); - expect(rosterSubjects(deployment(), { userId: 'me', activeUnitId: '99', manager: false }).map((subject) => subject.id)).toEqual(['dp-1']); - expect(rosterSubjects(deployment(), { userId: 'me', activeUnitId: null, manager: false }).map((subject) => subject.id)).toEqual(['dp-1']); - expect(rosterSubjects(deployment(), { userId: 'nobody', activeUnitId: null, manager: false })).toEqual([]); +it('offers exactly the scopes the server granted and opens on the crew report', () => { + expect(availableScopes(deployment()).map(scopeKey)).toEqual(['crew:du-1', 'person:dp-1']); + expect(scopeKey(defaultScope(deployment(), null))).toBe('crew:du-1'); + // The Engine 42 tablet: seated on unit 13, not on the roster. + const tablet = deployment({ PersonnelId: null, CrewUnitIds: ['du-2'], WritableSubjectIds: ['du-2', 'dp-3', 'de-2'] }); + expect(scopeKey(defaultScope(tablet, '13'))).toBe('crew:du-2'); + // A single resource files their own report. + const solo = deployment({ PersonnelId: 'dp-5', CrewUnitIds: [], WritableSubjectIds: ['dp-5'] }); + expect(availableScopes(solo).map(scopeKey)).toEqual(['person:dp-5']); + expect(scopeKey(defaultScope(solo, null))).toBe('person:dp-5'); + // A manager may open every active crew, their own and the deployment-wide DTR. + const manager = deployment({ CanManage: true, PersonnelId: null, CrewUnitIds: [], WritableSubjectIds: [] }); + expect(availableScopes(manager).map(scopeKey)).toEqual(['crew:du-1', 'crew:du-2', 'deployment']); + expect(availableScopes({ ...deployment(), TimeAccess: null })).toEqual([]); +}); + +it('limits the picker to the report scope and to subjects the person may write', () => { + expect(scopeSubjects(deployment(), { kind: 'crew', unitId: 'du-1' }).map((subject) => subject.id)).toEqual(['du-1', 'dp-1', 'dp-2', 'de-1']); + expect(scopeSubjects(deployment(), { kind: 'crew', unitId: 'du-1' }).find((subject) => subject.id === 'dp-2')?.label).toBe('Crew (FF)'); + expect(scopeSubjects(deployment(), { kind: 'crew', unitId: 'du-2' })).toEqual([]); + expect(scopeSubjects(deployment(), { kind: 'individual', personnelId: 'dp-1' }).map((subject) => subject.id)).toEqual(['dp-1']); + expect(scopeSubjects(deployment({ CanManage: true }), { kind: 'deployment' }).map((subject) => subject.id)).toEqual(['du-1', 'du-2', 'dp-1', 'dp-2', 'dp-3', 'dp-5', 'de-1', 'de-2']); +}); + +it('copies the unit row onto the crew only for writable rows of the same entry type', () => { + const rows = [ + entry({ SubjectType: 1, DeploymentPersonnelId: null, DeploymentUnitId: 'du-1', StartLocal: '2026-09-19T06:00', EndLocal: '2026-09-19T20:00', UnpaidBreakMinutes: 30, AgencySuppliedMeals: true }), + entry({ DeploymentPersonnelId: 'dp-2' }), + entry({ DeploymentPersonnelId: 'dp-3' }), + entry({ DeploymentPersonnelId: 'dp-1', EntryType: 2 }), + ]; + const applied = applyToCrew(rows, 0, (id) => id !== 'dp-3'); + expect(applied[1]).toMatchObject({ StartLocal: '2026-09-19T06:00', EndLocal: '2026-09-19T20:00', UnpaidBreakMinutes: 30, AgencySuppliedMeals: true }); + expect(applied[2]).toBe(rows[2]); + expect(applied[3]).toBe(rows[3]); + expect(hoursBySubject(applied)).toEqual({ 'du-1': 13.5, 'dp-2': 13.5, 'dp-3': 10, 'dp-1': 10 }); +}); + +it('finds the report for a scope and day, the report already covering a subject, and editability', () => { + const crew = report({ Entries: [entry({ DeploymentPersonnelId: 'dp-1' })] }); + const solo = report({ Id: 'r-2', Scope: 2, DeploymentUnitId: null, DeploymentPersonnelId: 'dp-5' }); + const voided = report({ Id: 'r-3', Status: 4 }); + expect(reportForScope([voided, crew, solo], '2026-09-19', { kind: 'crew', unitId: 'du-1' })?.Id).toBe('r-1'); + expect(reportForScope([crew, solo], '2026-09-19', { kind: 'individual', personnelId: 'dp-5' })?.Id).toBe('r-2'); + expect(reportForScope([crew], '2026-09-20', { kind: 'crew', unitId: 'du-1' })).toBeNull(); + expect(coveringReport([crew, solo], '2026-09-19', 'dp-1')?.Id).toBe('r-1'); + expect(coveringReport([crew], '2026-09-19', 'dp-1', 'r-1')).toBeNull(); + expect(isReportEditable(crew)).toBe(true); + expect(isReportEditable({ ...crew, CanAct: false })).toBe(false); + expect(isReportEditable({ ...crew, Status: 1 })).toBe(false); }); -it('matches a report by calendar day and maps server failures to short reason codes', () => { - const reports = [{ Id: 'r-1', ReportDate: '2026-09-19T00:00:00', Entries: [] }, { Id: 'r-2', ReportDate: '2026-09-20T00:00:00', Entries: [] }] as never; - expect(reportForDate(reports, '2026-09-20')?.Id).toBe('r-2'); - expect(reportForDate(reports, '2026-09-21')).toBeNull(); +it('maps server failures to short reason codes', () => { expect(operationsError({ response: { status: 403, headers: { 'x-resgrid-reason': 'deployments_disabled' } } })).toBe('disabled'); expect(operationsError({ response: { status: 401 } })).toBe('denied'); - expect(operationsError({ response: { status: 400, headers: { 'x-resgrid-reason': 'time_report_not_editable' } } })).toBe('time_report_not_editable'); + expect(operationsError({ response: { status: 400, headers: { 'x-resgrid-reason': 'timereports_subject_covered' } } })).toBe('timereports_subject_covered'); expect(operationsError({ response: { status: 400 } })).toBe('validation'); expect(operationsError({ response: { status: 200, data: { type: 'protected_data_required' } } })).toBe('locked'); expect(operationsError(new Error('boom'))).toBe('retry'); diff --git a/src/lib/operations/capabilities.ts b/src/lib/operations/capabilities.ts index 41f70935..b98c1dd7 100644 --- a/src/lib/operations/capabilities.ts +++ b/src/lib/operations/capabilities.ts @@ -1,11 +1,16 @@ import type { Href } from 'expo-router'; // What this app does with a deployment. The server enforces every rule again; this only shapes the UI. +// Dispatchers see deployments and their time reports read-only. export const operationsCapabilities = { - /** Write daily time report entries (Responder: own row; Unit: the active unit's crew). */ + /** Write crew / individual time reports (read-only here). */ editTime: false, /** Record odometer / engine / fuel readings against a deployment unit. */ recordUsage: false, + /** Add expenses (meals, fuel, lodging) with a receipt photo. */ + recordExpenses: false, + /** Approve submitted time reports when the person holds TimeReports_Approve. */ + approveTime: false, /** Draft and validate the CAL OES MARS F-42 from the field. */ draftF42: false, homeRoute: '/home' as Href, diff --git a/src/lib/operations/time.ts b/src/lib/operations/time.ts index cfca99a3..b9085e07 100644 --- a/src/lib/operations/time.ts +++ b/src/lib/operations/time.ts @@ -1,41 +1,57 @@ import type { Deployment, TimeEntry, TimeReport } from '@/models/v4/operations'; -import { TimeEntryType, TimeSubjectType } from '@/models/v4/operations'; +import { TimeEntryType, TimeReportScope, TimeReportStatus, TimeSubjectType } from '@/models/v4/operations'; -// Pure helpers behind the daily time report editor. Times are deployment-local wall clock: the -// server stores what the crew wrote on the paper DTR, so nothing here converts to UTC. +// Pure helpers behind the crew / individual time report editor. Entry times are the department-local wall +// clock the crew writes on a paper time report ("yyyy-MM-ddTHH:mm", StartLocal/EndLocal); the server does +// the zone conversion, so nothing here touches UTC or the device's time zone. const pad = (n: number) => String(n).padStart(2, '0'); export const localDateKey = (date = new Date()) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +export const shiftDateKey = (dateKey: string, days: number) => { + const [y, m, d] = dateKey.split('-').map(Number); + return localDateKey(new Date(y, m - 1, d + days)); +}; + export const timeOf = (value: string | null | undefined) => { const match = /T(\d{2}):(\d{2})/.exec(value ?? ''); return match ? `${match[1]}:${match[2]}` : ''; }; +export const dateOf = (value: string | null | undefined) => /^(\d{4}-\d{2}-\d{2})/.exec(value ?? '')?.[1] ?? ''; + export const isClockTime = (value: string) => /^([01]\d|2[0-3]):[0-5]\d$/.test(value); -export const combineDateTime = (dateKey: string, clock: string) => `${dateKey}T${clock}:00`; +export const combineDateTime = (dateKey: string, clock: string) => `${dateKey}T${clock}`; -const minutesOf = (value: string) => { - const match = /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value); +const minutesOf = (value: string | null | undefined) => { + const match = /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value ?? ''); if (!match) return null; const day = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])); return day / 60000 + Number(match[4]) * 60 + Number(match[5]); }; -export const entryMinutes = (entry: Pick) => { - const start = minutesOf(entry.StartTime); - const end = minutesOf(entry.EndTime); +/** An end clock at or before the start clock is the next morning (a shift across midnight). */ +export const endLocalFor = (startLocal: string, clock: string) => { + const day = dateOf(startLocal); + return clock <= timeOf(startLocal) ? combineDateTime(shiftDateKey(day, 1), clock) : combineDateTime(day, clock); +}; + +export const entryMinutes = (entry: Pick) => { + const start = minutesOf(entry.StartLocal); + const end = minutesOf(entry.EndLocal); if (start == null || end == null || end < start) return 0; return Math.max(0, end - start - (entry.UnpaidBreakMinutes || 0)); }; -export const entryHours = (entry: Pick) => Math.round((entryMinutes(entry) / 60) * 100) / 100; +export const entryHours = (entry: Pick) => Math.round((entryMinutes(entry) / 60) * 100) / 100; export const sumHours = (entries: TimeEntry[]) => Math.round(entries.reduce((total, entry) => total + entryMinutes(entry), 0) / 0.6) / 100; -export const subjectKey = (entry: TimeEntry) => `${entry.SubjectType}:${entry.DeploymentPersonnelId ?? entry.DeploymentUnitId ?? entry.DeploymentEquipmentId ?? ''}`; +export const subjectIdOf = (entry: Pick) => entry.DeploymentPersonnelId ?? entry.DeploymentUnitId ?? entry.DeploymentEquipmentId ?? ''; + +export const subjectKey = (entry: TimeEntry) => `${entry.SubjectType}:${subjectIdOf(entry)}`; export interface EntryIssue { code: 'missing_subject' | 'missing_time' | 'end_before_start' | 'overlap'; @@ -46,10 +62,9 @@ export const validateEntries = (entries: TimeEntry[]): EntryIssue[] => { const issues: EntryIssue[] = []; const seen: { key: string; start: number; end: number; index: number }[] = []; entries.forEach((entry, index) => { - const subject = entry.DeploymentPersonnelId || entry.DeploymentUnitId || entry.DeploymentEquipmentId; - if (!subject) issues.push({ code: 'missing_subject', index }); - const start = minutesOf(entry.StartTime); - const end = minutesOf(entry.EndTime); + if (!subjectIdOf(entry)) issues.push({ code: 'missing_subject', index }); + const start = minutesOf(entry.StartLocal); + const end = minutesOf(entry.EndLocal); if (start == null || end == null) { issues.push({ code: 'missing_time', index }); return; @@ -71,69 +86,151 @@ export interface EntrySubject { label: string; } -export const newEntry = (subject: EntrySubject, dateKey: string, sortOrder: number): TimeEntry => ({ +export const newEntry = (subject: EntrySubject, dateKey: string, sortOrder: number, template?: Partial): TimeEntry => ({ Id: null, SubjectType: subject.type, DeploymentPersonnelId: subject.type === TimeSubjectType.Personnel ? subject.id : null, DeploymentUnitId: subject.type === TimeSubjectType.Unit ? subject.id : null, DeploymentEquipmentId: subject.type === TimeSubjectType.Equipment ? subject.id : null, - EntryType: TimeEntryType.Deployment, - StartTime: combineDateTime(dateKey, '08:00'), - EndTime: combineDateTime(dateKey, '18:00'), - PaidBreakMinutes: 0, - UnpaidBreakMinutes: 0, + EntryType: template?.EntryType ?? TimeEntryType.Deployment, + StartLocal: template?.StartLocal ?? combineDateTime(dateKey, '08:00'), + EndLocal: template?.EndLocal ?? combineDateTime(dateKey, '18:00'), + PaidBreakMinutes: template?.PaidBreakMinutes ?? 0, + UnpaidBreakMinutes: template?.UnpaidBreakMinutes ?? 0, CrewSizeSnapshot: null, CertificationCode: null, MileageKm: null, FuelDeductionLitres: null, - AgencySuppliedMeals: false, - AgencySuppliedAccommodation: false, + AgencySuppliedMeals: template?.AgencySuppliedMeals ?? false, + AgencySuppliedAccommodation: template?.AgencySuppliedAccommodation ?? false, Notes: null, SortOrder: sortOrder, }); -export interface RosterScope { - userId: string | null; - activeUnitId: string | null; - manager: boolean; -} +// --------------------------------------------------------------------------------------------------- +// Report scopes. A deployed unit files one Crew Time Report a day for the unit, its crew and its +// equipment; a single resource files an individual report; a manager may keep the deployment-wide DTR. +// --------------------------------------------------------------------------------------------------- -const named = (name: string, callSign?: string | null) => (callSign ? `${name} (${callSign})` : name); +export type TimeScope = { kind: 'crew'; unitId: string } | { kind: 'individual'; personnelId: string } | { kind: 'deployment' }; -// Who a person may write time for. A manager sees the whole roster; a crew member on an active unit -// sees that unit, its crew and its equipment; anyone else only their own roster row. The server -// enforces the same rule, this only keeps the picker honest. -export const rosterSubjects = (deployment: Deployment, scope: RosterScope): EntrySubject[] => { - const units = deployment.Units.filter((unit) => unit.IsActive); - const personnel = deployment.Personnel.filter((person) => person.IsActive); - const equipment = deployment.Equipment.filter((item) => item.IsActive); - const people = (rows: typeof personnel) => rows.map((person) => ({ type: TimeSubjectType.Personnel, id: person.Id, label: named(person.Name, person.CallSign) })); - const vehicles = (rows: typeof units) => rows.map((unit) => ({ type: TimeSubjectType.Unit, id: unit.Id, label: named(unit.UnitName, unit.CallSign) })); - const gear = (rows: typeof equipment) => rows.map((item) => ({ type: TimeSubjectType.Equipment, id: item.Id, label: item.Name })); - if (scope.manager) return [...people(personnel), ...vehicles(units), ...gear(equipment)]; - if (scope.activeUnitId) { - const mine = units.filter((unit) => String(unit.UnitId) === scope.activeUnitId); - if (mine.length > 0) { - const ids = new Set(mine.map((unit) => unit.Id)); - return [ - ...people(personnel.filter((person) => !!person.DeploymentUnitId && ids.has(person.DeploymentUnitId))), - ...vehicles(mine), - ...gear(equipment.filter((item) => !!item.DeploymentUnitId && ids.has(item.DeploymentUnitId))), - ]; - } +export const scopeKey = (scope: TimeScope | null | undefined) => (!scope ? '' : scope.kind === 'crew' ? `crew:${scope.unitId}` : scope.kind === 'individual' ? `person:${scope.personnelId}` : 'deployment'); + +export const reportScope = (report: Pick): TimeScope => + report.Scope === TimeReportScope.Crew && report.DeploymentUnitId + ? { kind: 'crew', unitId: report.DeploymentUnitId } + : report.Scope === TimeReportScope.Individual && report.DeploymentPersonnelId + ? { kind: 'individual', personnelId: report.DeploymentPersonnelId } + : { kind: 'deployment' }; + +// A report's date arrives as a date-time; match on the calendar day only. +export const reportsOnDate = (reports: TimeReport[], dateKey: string) => reports.filter((report) => dateOf(report.ReportDate) === dateKey && report.Status !== TimeReportStatus.Void); + +export const reportForScope = (reports: TimeReport[], dateKey: string, scope: TimeScope | null) => + scope ? (reportsOnDate(reports, dateKey).find((report) => scopeKey(reportScope(report)) === scopeKey(scope)) ?? null) : null; + +/** The report that already carries this subject's time today, if it is not the one being edited. */ +export const coveringReport = (reports: TimeReport[], dateKey: string, subjectId: string, exceptReportId?: string | null) => + reportsOnDate(reports, dateKey).find((report) => report.Id !== exceptReportId && report.Entries.some((entry) => subjectIdOf(entry) === subjectId)) ?? null; + +/** The scopes this person may open, straight from the server's TimeAccess (never inferred on the device). */ +export const availableScopes = (deployment: Deployment): TimeScope[] => { + const access = deployment.TimeAccess; + if (!access) return []; + const activeUnits = new Set(deployment.Units.filter((unit) => unit.IsActive).map((unit) => unit.Id)); + const scopes: TimeScope[] = []; + const crewUnits = access.CanManage ? deployment.Units.filter((unit) => unit.IsActive).map((unit) => unit.Id) : access.CrewUnitIds.filter((id) => activeUnits.has(id)); + crewUnits.forEach((unitId) => scopes.push({ kind: 'crew', unitId })); + if (access.PersonnelId) scopes.push({ kind: 'individual', personnelId: access.PersonnelId }); + if (access.CanManage) scopes.push({ kind: 'deployment' }); + return scopes; +}; + +/** + * The scope a screen opens on: the active unit's crew report on the unit tablet; otherwise the crew the person + * is seated on (the crew boss files the CTR); otherwise their own report; otherwise a manager's first scope. + */ +export const defaultScope = (deployment: Deployment, activeUnitId: string | null): TimeScope | null => { + const scopes = availableScopes(deployment); + if (activeUnitId) { + const unit = deployment.Units.find((candidate) => candidate.IsActive && String(candidate.UnitId) === activeUnitId); + const crew = unit ? scopes.find((scope) => scope.kind === 'crew' && scope.unitId === unit.Id) : undefined; + if (crew) return crew; + } + const own = deployment.TimeAccess?.PersonnelId ? deployment.Personnel.find((person) => person.Id === deployment.TimeAccess?.PersonnelId) : undefined; + const ownCrew = own?.DeploymentUnitId ? scopes.find((scope) => scope.kind === 'crew' && scope.unitId === own.DeploymentUnitId) : undefined; + if (ownCrew) return ownCrew; + if (!deployment.TimeAccess?.CanManage) { + const seated = scopes.find((scope) => scope.kind === 'crew'); + if (seated) return seated; } - return people(personnel.filter((person) => person.UserId === scope.userId)); + return scopes.find((scope) => scope.kind === 'individual') ?? scopes[0] ?? null; }; -export const subjectLabel = (entry: TimeEntry, subjects: EntrySubject[]) => { - const id = entry.DeploymentPersonnelId ?? entry.DeploymentUnitId ?? entry.DeploymentEquipmentId ?? ''; - return subjects.find((subject) => subject.type === entry.SubjectType && subject.id === id)?.label ?? ''; +const named = (name: string, callSign?: string | null) => (callSign ? `${name} (${callSign})` : name); + +export const canWriteSubject = (deployment: Deployment, subjectId: string) => !!deployment.TimeAccess?.CanManage || (deployment.TimeAccess?.WritableSubjectIds ?? []).includes(subjectId); + +/** Who or what a report in this scope may carry that the person may write: the picker for new rows. */ +export const scopeSubjects = (deployment: Deployment, scope: TimeScope | null): EntrySubject[] => { + if (!scope) return []; + const inUnit = (unitId?: string | null) => scope.kind === 'deployment' || (scope.kind === 'crew' && unitId === scope.unitId); + const people = deployment.Personnel.filter((person) => person.IsActive && (scope.kind === 'individual' ? person.Id === scope.personnelId : inUnit(person.DeploymentUnitId))); + const units = scope.kind === 'individual' ? [] : deployment.Units.filter((unit) => unit.IsActive && inUnit(unit.Id)); + const equipment = scope.kind === 'individual' ? [] : deployment.Equipment.filter((item) => item.IsActive && inUnit(item.DeploymentUnitId)); + return [ + ...units.map((unit) => ({ type: TimeSubjectType.Unit, id: unit.Id, label: named(unit.UnitName, unit.CallSign) })), + ...people.map((person) => ({ type: TimeSubjectType.Personnel, id: person.Id, label: named(person.Name, person.CallSign) })), + ...equipment.map((item) => ({ type: TimeSubjectType.Equipment, id: item.Id, label: item.Name })), + ].filter((subject) => canWriteSubject(deployment, subject.id)); }; -// A report's date arrives as a date-time; match on the calendar day only. -export const reportForDate = (reports: TimeReport[], dateKey: string) => reports.find((report) => (report.ReportDate ?? '').slice(0, 10) === dateKey) ?? null; +/** Every roster name by subject id (active or since released), so a stored row always has a label. */ +export const subjectNames = (deployment: Deployment) => { + const names: Record = {}; + deployment.Units.forEach((unit) => (names[unit.Id] = named(unit.UnitName, unit.CallSign))); + deployment.Personnel.forEach((person) => (names[person.Id] = named(person.Name, person.CallSign))); + deployment.Equipment.forEach((item) => (names[item.Id] = item.Name)); + return names; +}; + +export const scopeName = (deployment: Deployment, scope: TimeScope | null) => { + if (!scope || scope.kind === 'deployment') return ''; + return subjectNames(deployment)[scope.kind === 'crew' ? scope.unitId : scope.personnelId] ?? ''; +}; + +/** + * The CTR shortcut: most crews work the unit's shift together, so copy one row's times, breaks and agency + * supplies onto every other row of the same entry type the person may write. Rows that differ are then + * edited one by one. + */ +export const applyToCrew = (entries: TimeEntry[], sourceIndex: number, writable: (subjectId: string) => boolean): TimeEntry[] => { + const source = entries[sourceIndex]; + if (!source) return entries; + return entries.map((entry, index) => + index === sourceIndex || entry.EntryType !== source.EntryType || !writable(subjectIdOf(entry)) + ? entry + : { + ...entry, + StartLocal: source.StartLocal, + EndLocal: source.EndLocal, + PaidBreakMinutes: source.PaidBreakMinutes, + UnpaidBreakMinutes: source.UnpaidBreakMinutes, + AgencySuppliedMeals: source.AgencySuppliedMeals, + AgencySuppliedAccommodation: source.AgencySuppliedAccommodation, + } + ); +}; + +/** Hours per subject for the report summary (the crew boss's check before signing). */ +export const hoursBySubject = (entries: TimeEntry[]) => + entries.reduce>((totals, entry) => { + const id = subjectIdOf(entry); + totals[id] = Math.round(((totals[id] ?? 0) + entryHours(entry)) * 100) / 100; + return totals; + }, {}); -export const isReportEditable = (report: TimeReport | null) => !!report && report.Status === 0; +export const isReportEditable = (report: TimeReport | null) => !!report && report.Status === TimeReportStatus.Draft && report.CanAct; export const operationsError = (error: unknown): string => { const response = (error as { response?: { status?: number; headers?: Record; data?: { type?: string } } })?.response; diff --git a/src/lib/unit-status-helpers.ts b/src/lib/unit-status-helpers.ts index 282d1480..3ac28a47 100644 --- a/src/lib/unit-status-helpers.ts +++ b/src/lib/unit-status-helpers.ts @@ -11,9 +11,12 @@ import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; * type name) per group, so matching by id here is reliable — unlike the server's per-unit endpoint * which resolves the type by name and silently falls back to the default statuses on a mismatch. * - * Only the statuses attached to the unit's type are returned. Falls back to the server-provided - * scoped list, then the default group, when no custom-set group matches (i.e. the unit type has no - * custom statuses). + * Only the statuses attached to the unit's type are returned. When no custom-set group matches (i.e. + * the unit type has no custom statuses), the department default group (`UnitType === '0'`) is used + * before the server-provided per-unit list: the default group carries the real default unit statuses + * with their destination `Detail` types (Responding / On Scene / Staging accept calls), whereas older + * servers answer the per-unit endpoint with a Detail-0 list that can never carry a call destination. + * The per-unit list is only the last resort, when the grouped response has no default group. */ export function resolveUnitStatusOptions( unit: Pick | null | undefined, @@ -34,10 +37,10 @@ export function resolveUnitStatusOptions( if (byType?.Statuses?.length) return byType.Statuses; } - // 3. Server-provided per-unit list (already scoped for units without a custom set). - if (fallback.length) return fallback; - - // 4. Department default unit statuses (the "0" group). + // 3. Department default unit statuses (the "0" group), which carry the proper Detail types. const defaultGroup = list.find((group) => group.UnitType === '0'); - return defaultGroup?.Statuses ?? []; + if (defaultGroup?.Statuses?.length) return defaultGroup.Statuses; + + // 4. Server-provided per-unit list (last resort when the grouped response has no default group). + return fallback; } diff --git a/src/models/v4/calls/callExtraDataResult.ts b/src/models/v4/calls/callExtraDataResult.ts index bb7f0061..6eaa07ec 100644 --- a/src/models/v4/calls/callExtraDataResult.ts +++ b/src/models/v4/calls/callExtraDataResult.ts @@ -1,35 +1,10 @@ import { BaseV4Request } from '../baseV4Request'; import { CallExtraDataResultData } from './callExtraDataResultData'; -export interface CallExtraDataResult { - Success: boolean; - Message: string | null; - CallExtraData: { - AdditionalInfo: Record; - ContactInfo: { - Name: string; - Phone: string; - Email: string; - }; - Protocols: { - Name: string; - Description: string; - }[]; - Dispatched: { - Name: string; - Unit: string; - Status: string; - }[]; - Timeline: { - Timestamp: string; - User: string; - Unit: string; - Status: string; - }[]; - // Add other fields as needed - }; -} - +/** + * Response of `/Calls/GetCallExtraData`: the standard v4 envelope plus the call's form data, + * `Activity` (unit/personnel status events on the call), `Dispatches`, priority and protocols. + */ export class CallExtraDataResult extends BaseV4Request { public Data: CallExtraDataResultData = new CallExtraDataResultData(); } diff --git a/src/models/v4/calls/dispatchedEventResultData.ts b/src/models/v4/calls/dispatchedEventResultData.ts index 36ff38fe..67ae00f5 100644 --- a/src/models/v4/calls/dispatchedEventResultData.ts +++ b/src/models/v4/calls/dispatchedEventResultData.ts @@ -11,4 +11,13 @@ export class DispatchedEventResultData { public StatusText: string = ''; public StatusColor: string = ''; + + /** + * How a status entry was tied to this call (server `StatusDestinationSources`, see `@/lib/activity-link`): + * 1 Explicit (the sender chose the call); 2 CarryForward, 3 Dispatch, 4 Unit (auto-linked: sent without a + * call and linked by the server from the previous status, the one open dispatch, or the unit a person rode); + * 5 Inferred (set with no destination by a unit/person dispatched to this call while working it; derived at + * read time). Null/absent on older rows and non-status entries. + */ + public DestinationSource?: number | null = null; } diff --git a/src/models/v4/contacts/contactResultData.ts b/src/models/v4/contacts/contactResultData.ts index 483658e6..1fd19d88 100644 --- a/src/models/v4/contacts/contactResultData.ts +++ b/src/models/v4/contacts/contactResultData.ts @@ -66,4 +66,33 @@ export interface ContactResultData { * across the list: a field withheld on one contact must not mark it on every other one. */ RedactedFields?: string[]; + IsProtected?: boolean; + ProtectedReason?: string | null; + /** The contact category's name and color (both list rows and the detail read). */ + CategoryName?: string | null; + CategoryColor?: string | null; + /** Resolved addresses (GetContactById only). */ + PhysicalAddress?: ContactAddressData | null; + MailingAddress?: ContactAddressData | null; + /** Mobile-visible custom field values with their labels, in form order (GetContactById only). */ + CustomFields?: ContactCustomFieldData[]; +} + +export interface ContactAddressData { + Address1?: string | null; + City?: string | null; + State?: string | null; + PostalCode?: string | null; + Country?: string | null; + /** One-line form for display and for a maps app. */ + Formatted?: string | null; +} + +export interface ContactCustomFieldData { + UdfFieldId: string; + Label: string; + Value: string; + FieldDataType: number; + GroupName?: string | null; + SortOrder: number; } diff --git a/src/models/v4/operations/index.ts b/src/models/v4/operations/index.ts index 4e81c640..0ca658ad 100644 --- a/src/models/v4/operations/index.ts +++ b/src/models/v4/operations/index.ts @@ -12,6 +12,9 @@ export interface OperationsResult { export const DeploymentStatus = { Planned: 0, Standby: 1, Active: 2, Demobilizing: 3, Completed: 4, Cancelled: 5 } as const; export const DeploymentFinanceMode = { OperationalOnly: 0, CostRecovery: 1, Billable: 2 } as const; export const TimeReportStatus = { Draft: 0, Submitted: 1, Approved: 2, Billed: 3, Void: 4 } as const; +/** Who a daily time report covers: the whole deployment (manager's DTR), one unit's crew (CTR) or one person. */ +export const TimeReportScope = { Deployment: 0, Crew: 1, Individual: 2 } as const; +export const ExpenseType = { PerDiemMeal: 0, Accommodation: 1, PrivateAccommodation: 2, Ferry: 3, Fuel: 4, SupplyRestock: 5, Other: 6 } as const; export const TimeSubjectType = { Personnel: 0, Unit: 1, Equipment: 2 } as const; export const TimeEntryType = { Deployment: 0, Standby: 1, Travel: 2 } as const; export const UsagePhase = { Mobilization: 0, Standby: 1, Incident: 2, Return: 3 } as const; @@ -66,6 +69,22 @@ export interface Deployment { Units: DeploymentUnit[]; Personnel: DeploymentPersonnel[]; Equipment: DeploymentEquipment[]; + /** The caller's time scope, computed by the server (detail reads only). */ + TimeAccess?: DeploymentTimeAccess | null; +} + +/** + * What the signed-in person may do with this deployment's time: their own roster row (individual report), + * the deployed units they crew — on the deployment roster for that unit, or seated on the apparatus through + * an active unit role — (crew time report) and every subject id they may write. The server re-checks it all. + */ +export interface DeploymentTimeAccess { + CanManage: boolean; + CanApprove: boolean; + PersonnelId?: string | null; + CrewUnitIds: string[]; + WritableSubjectIds: string[]; + TimeZone?: string | null; } export interface TimeEntry { @@ -75,8 +94,12 @@ export interface TimeEntry { DeploymentUnitId?: string | null; DeploymentEquipmentId?: string | null; EntryType: number; - StartTime: string; - EndTime: string; + /** UTC instant from the server; the field apps read and write the local wall clock below. */ + StartTime?: string | null; + EndTime?: string | null; + /** Department-local wall clock "yyyy-MM-ddTHH:mm"; the server converts it in the department's zone. */ + StartLocal: string; + EndLocal: string; PaidBreakMinutes: number; UnpaidBreakMinutes: number; CrewSizeSnapshot?: number | null; @@ -95,15 +118,64 @@ export interface TimeReport { DeploymentId: string; ReportNumber: number; ReportDate: string; + /** TimeReportScope value. */ + Scope: number; + DeploymentUnitId?: string | null; + DeploymentPersonnelId?: string | null; + /** The caller may write, sign and submit this report (its scope is theirs). */ + CanAct: boolean; Status: number; IncidentNumber?: string | null; + ResourceOrderNumber?: string | null; RequestNumber?: string | null; + NoClear8?: boolean; + UnsafeConditionsStandDown?: boolean; + ContractorSignedByUserId?: string | null; + ContractorSignedOn?: string | null; + CustomerSignerName?: string | null; + CustomerSignedOn?: string | null; + SubmittedByUserId?: string | null; SubmittedOn?: string | null; + ApprovedByUserId?: string | null; ApprovedOn?: string | null; Notes?: string | null; Entries: TimeEntry[]; } +export interface Expense { + Id: string; + DeploymentId: string; + TimeReportId?: string | null; + ExpenseDate: string; + ExpenseType: number; + MealCode?: string | null; + City?: string | null; + Description?: string | null; + Amount: number; + Currency?: string | null; + PreApproved: boolean; + Billable: boolean; + ReceiptAttachmentId?: number | null; + AddedByUserId?: string | null; + AddedOn?: string | null; +} + +export interface ExpenseInput { + Id?: string | null; + DeploymentId: string; + TimeReportId?: string | null; + ExpenseDate: string; + ExpenseType: number; + City?: string | null; + Description?: string | null; + Amount: number; + Currency?: string | null; + /** Receipt photo, base64 (the server files it as a Receipt attachment). */ + ReceiptData?: string | null; + ReceiptFileName?: string | null; + ReceiptContentType?: string | null; +} + export interface TimeReportIssue { Code: string; SubjectId?: string | null; diff --git a/src/stores/calls/detail-store.ts b/src/stores/calls/detail-store.ts index 1ceddb48..9b2c7417 100644 --- a/src/stores/calls/detail-store.ts +++ b/src/stores/calls/detail-store.ts @@ -85,7 +85,7 @@ export const useCallDetailStore = create((set, get) => ({ }); } else { set({ - error: callResult.Message || callExtraDataResult.Message || 'Failed to fetch call details', + error: callResult?.Message || 'Failed to fetch call details', isLoading: false, }); } diff --git a/src/stores/contacts/store.ts b/src/stores/contacts/store.ts index 024279d9..cf6ec8ed 100644 --- a/src/stores/contacts/store.ts +++ b/src/stores/contacts/store.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { getContactNotes } from '@/api/contacts/contactNotes'; -import { getAllContacts } from '@/api/contacts/contacts'; +import { getAllContacts, getContact } from '@/api/contacts/contacts'; import { type ContactNoteResultData } from '@/models/v4/contacts/contactNoteResultData'; import { type ContactResultData } from '@/models/v4/contacts/contactResultData'; @@ -10,13 +10,18 @@ interface ContactsState { contactNotes: Record; searchQuery: string; selectedContactId: string | null; + // Full record from GetContactById — the GetAllContacts list payload is slim (no address, custom fields + // or category), so the details sheet needs this to show every field + selectedContactDetails: ContactResultData | null; isDetailsOpen: boolean; isLoading: boolean; + isDetailsLoading: boolean; isNotesLoading: boolean; error: string | null; // Actions fetchContacts: (forceRefresh?: boolean) => Promise; - fetchContactNotes: (contactId: string) => Promise; + fetchContactDetails: (contactId: string) => Promise; + fetchContactNotes: (contactId: string, force?: boolean) => Promise; setSearchQuery: (query: string) => void; selectContact: (id: string) => void; closeDetails: () => void; @@ -27,8 +32,10 @@ export const useContactsStore = create((set, get) => ({ contactNotes: {}, searchQuery: '', selectedContactId: null, + selectedContactDetails: null, isDetailsOpen: false, isLoading: false, + isDetailsLoading: false, isNotesLoading: false, error: null, @@ -42,11 +49,12 @@ export const useContactsStore = create((set, get) => ({ } }, - fetchContactNotes: async (contactId: string) => { + fetchContactNotes: async (contactId: string, force = false) => { const { contactNotes } = get(); - // Don't fetch if we already have notes for this contact - if (contactNotes[contactId]) { + // Don't fetch if we already have notes for this contact, unless the sheet asks for a fresh read + // (opening a contact, or after protected data was unlocked, when the cached notes may be redacted). + if (contactNotes[contactId] && !force) { return; } @@ -68,9 +76,29 @@ export const useContactsStore = create((set, get) => ({ } }, + fetchContactDetails: async (contactId: string) => { + set({ isDetailsLoading: true, selectedContactDetails: null }); + try { + const response = await getContact(contactId); + if (get().selectedContactId !== contactId) { + return; // stale response — a newer contact was selected meanwhile + } + set({ selectedContactDetails: response.Data || null, isDetailsLoading: false }); + } catch { + if (get().selectedContactId !== contactId) { + return; + } + // The sheet falls back to the slim list record already in the store + set({ isDetailsLoading: false, selectedContactDetails: null }); + } + }, + setSearchQuery: (query) => set({ searchQuery: query }), - selectContact: (id) => set({ selectedContactId: id, isDetailsOpen: true }), + selectContact: (id) => { + set({ selectedContactId: id, isDetailsOpen: true }); + void get().fetchContactDetails(id); + }, - closeDetails: () => set({ isDetailsOpen: false }), + closeDetails: () => set({ isDetailsOpen: false, selectedContactDetails: null }), })); diff --git a/src/stores/dispatch/__tests__/personnel-actions-store.test.ts b/src/stores/dispatch/__tests__/personnel-actions-store.test.ts new file mode 100644 index 00000000..17c6676e --- /dev/null +++ b/src/stores/dispatch/__tests__/personnel-actions-store.test.ts @@ -0,0 +1,96 @@ +import { savePersonsStatuses } from '@/api/personnel/personnelStatuses'; +import { CustomStateDetailType, DestinationEntityType } from '@/lib/destination-helpers'; +import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { type GroupResultData } from '@/models/v4/groups/groupsResultData'; +import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; +import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; + +import { usePersonnelActionsStore } from '../personnel-actions-store'; + +jest.mock('@/api/personnel/personnelStatuses', () => ({ + savePersonsStatuses: jest.fn(), +})); + +jest.mock('@/api/personnel/personnelStaffing', () => ({ + savePersonsStaffings: jest.fn(), +})); + +const mockSavePersonsStatuses = savePersonsStatuses as jest.MockedFunction; + +const person = (UserId: string): PersonnelInfoResultData => ({ UserId, FirstName: 'Pat', LastName: UserId, StatusDestinationId: '' }) as PersonnelInfoResultData; +const status = (Id: number, Text: string, Detail: number): StatusesResultData => ({ Id, Text, Detail, Note: 0 }) as StatusesResultData; +const call: CallResultData = { CallId: '1234', Number: '26-1', Name: 'Structure Fire', State: 0 } as CallResultData; +const station: GroupResultData = { GroupId: '55', Name: 'Station 5' } as GroupResultData; + +const responding = status(2, 'Responding', CustomStateDetailType.CallsAndStations); +const onScene = status(3, 'On Scene', CustomStateDetailType.Calls); +const standingBy = status(4, 'Standing By', CustomStateDetailType.Stations); +const notResponding = status(1, 'Not Responding', CustomStateDetailType.None); + +const lastPayload = () => mockSavePersonsStatuses.mock.calls[mockSavePersonsStatuses.mock.calls.length - 1][0]; + +describe('usePersonnelActionsStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSavePersonsStatuses.mockResolvedValue({} as any); + usePersonnelActionsStore.getState().reset(); + }); + + it('keeps the call destination sticky after a successful submit so the next status carries it too', async () => { + usePersonnelActionsStore.getState().openActions(person('p1')); + usePersonnelActionsStore.getState().setStatusSelectedCall(call); + + await expect(usePersonnelActionsStore.getState().submitStatus({ status: responding })).resolves.toBe(true); + expect(lastPayload()).toMatchObject({ UserIds: ['p1'], Type: '2', RespondingTo: '1234', RespondingToType: DestinationEntityType.Call }); + + const afterSubmit = usePersonnelActionsStore.getState(); + expect(afterSubmit.selectedStatus).toBeNull(); + expect(afterSubmit.statusDestinationType).toBe('call'); + expect(afterSubmit.statusSelectedCall).toBe(call); + + await usePersonnelActionsStore.getState().submitStatus({ status: onScene }); + expect(lastPayload()).toMatchObject({ Type: '3', RespondingTo: '1234', RespondingToType: DestinationEntityType.Call }); + }); + + it('does not send a destination the status Detail does not support', async () => { + usePersonnelActionsStore.getState().openActions(person('p1')); + usePersonnelActionsStore.getState().setStatusSelectedCall(call); + + await usePersonnelActionsStore.getState().submitStatus({ status: notResponding }); + expect(lastPayload()).toMatchObject({ Type: '1', RespondingTo: '', RespondingToType: null }); + + await usePersonnelActionsStore.getState().submitStatus({ status: standingBy }); + expect(lastPayload()).toMatchObject({ Type: '4', RespondingTo: '', RespondingToType: null }); + + usePersonnelActionsStore.getState().setStatusSelectedStation(station); + await usePersonnelActionsStore.getState().submitStatus({ status: onScene }); + expect(lastPayload()).toMatchObject({ Type: '3', RespondingTo: '', RespondingToType: null }); + + await usePersonnelActionsStore.getState().submitStatus({ status: standingBy }); + expect(lastPayload()).toMatchObject({ Type: '4', RespondingTo: '55', RespondingToType: DestinationEntityType.Station }); + }); + + it('presets the call for an explicit call context and sends it even when the status Detail does not list calls', async () => { + usePersonnelActionsStore.getState().openActions(person('p1'), { callContext: call }); + + expect(usePersonnelActionsStore.getState().statusDestinationType).toBe('call'); + expect(usePersonnelActionsStore.getState().statusSelectedCall).toBe(call); + + await usePersonnelActionsStore.getState().submitStatus({ status: notResponding }); + expect(lastPayload()).toMatchObject({ UserIds: ['p1'], Type: '1', RespondingTo: '1234', RespondingToType: DestinationEntityType.Call }); + }); + + it('starts a fresh session and form when opened for another person', () => { + usePersonnelActionsStore.getState().openActions(person('p1'), { callContext: call }); + const firstSession = usePersonnelActionsStore.getState().actionsSessionId; + + usePersonnelActionsStore.getState().openActions(person('p2')); + const reopened = usePersonnelActionsStore.getState(); + + expect(reopened.actionsSessionId).toBe(firstSession + 1); + expect(reopened.selectedPersonnel?.UserId).toBe('p2'); + expect(reopened.callContext).toBeNull(); + expect(reopened.statusDestinationType).toBe('none'); + expect(reopened.statusSelectedCall).toBeNull(); + }); +}); diff --git a/src/stores/dispatch/__tests__/unit-actions-store.test.ts b/src/stores/dispatch/__tests__/unit-actions-store.test.ts new file mode 100644 index 00000000..45a87e60 --- /dev/null +++ b/src/stores/dispatch/__tests__/unit-actions-store.test.ts @@ -0,0 +1,122 @@ +import { saveUnitStatus } from '@/api/units/unitStatuses'; +import { CustomStateDetailType, DestinationEntityType } from '@/lib/destination-helpers'; +import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { type GroupResultData } from '@/models/v4/groups/groupsResultData'; +import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; +import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; + +import { useUnitActionsStore } from '../unit-actions-store'; + +jest.mock('@/api/units/unitStatuses', () => ({ + saveUnitStatus: jest.fn(), +})); + +const mockSaveUnitStatus = saveUnitStatus as jest.MockedFunction; + +const unit = (UnitId: string): UnitInfoResultData => ({ UnitId, Name: `Unit ${UnitId}`, CurrentDestinationId: '' }) as UnitInfoResultData; +const status = (Id: number, Text: string, Detail: number): StatusesResultData => ({ Id, Text, Detail, Note: 0 }) as StatusesResultData; +const call: CallResultData = { CallId: '1234', Number: '26-1', Name: 'Structure Fire', State: 0 } as CallResultData; +const station: GroupResultData = { GroupId: '55', Name: 'Station 5' } as GroupResultData; + +const responding = status(3, 'Responding', CustomStateDetailType.Calls); +const onScene = status(6, 'On Scene', CustomStateDetailType.Calls); +const returning = status(7, 'Returning', CustomStateDetailType.Stations); +const outOfService = status(8, 'Out of Service', CustomStateDetailType.None); + +const lastPayload = () => mockSaveUnitStatus.mock.calls[mockSaveUnitStatus.mock.calls.length - 1][0]; + +describe('useUnitActionsStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSaveUnitStatus.mockResolvedValue({} as any); + useUnitActionsStore.getState().reset(); + }); + + it('keeps the call destination sticky after a successful submit so the next status carries it too', async () => { + const store = useUnitActionsStore.getState(); + store.openActions(unit('u1')); + store.setStatusSelectedCall(call); + + await expect(useUnitActionsStore.getState().submitStatus({ status: responding })).resolves.toBe(true); + expect(lastPayload()).toMatchObject({ Id: 'u1', Type: '3', RespondingTo: '1234', RespondingToType: DestinationEntityType.Call }); + + const afterSubmit = useUnitActionsStore.getState(); + expect(afterSubmit.selectedStatus).toBeNull(); + expect(afterSubmit.statusNote).toBe(''); + expect(afterSubmit.statusDestinationType).toBe('call'); + expect(afterSubmit.statusSelectedCall).toBe(call); + + await useUnitActionsStore.getState().submitStatus({ status: onScene }); + expect(lastPayload()).toMatchObject({ Type: '6', RespondingTo: '1234', RespondingToType: DestinationEntityType.Call }); + }); + + it('does not send a destination the status Detail does not support', async () => { + const store = useUnitActionsStore.getState(); + store.openActions(unit('u1')); + store.setStatusSelectedCall(call); + + await useUnitActionsStore.getState().submitStatus({ status: outOfService }); + expect(lastPayload()).toMatchObject({ Type: '8', RespondingTo: '', RespondingToType: null }); + + await useUnitActionsStore.getState().submitStatus({ status: returning }); + expect(lastPayload()).toMatchObject({ Type: '7', RespondingTo: '', RespondingToType: null }); + + useUnitActionsStore.getState().setStatusSelectedStation(station); + await useUnitActionsStore.getState().submitStatus({ status: returning }); + expect(lastPayload()).toMatchObject({ Type: '7', RespondingTo: '55', RespondingToType: DestinationEntityType.Station }); + + await useUnitActionsStore.getState().submitStatus({ status: onScene }); + expect(lastPayload()).toMatchObject({ Type: '6', RespondingTo: '', RespondingToType: null }); + }); + + it('presets the call for an explicit call context and sends it even when the status Detail does not list calls', async () => { + useUnitActionsStore.getState().openActions(unit('u1'), { callContext: call }); + + const opened = useUnitActionsStore.getState(); + expect(opened.callContext).toBe(call); + expect(opened.statusDestinationType).toBe('call'); + expect(opened.statusSelectedCall).toBe(call); + + await useUnitActionsStore.getState().submitStatus({ status: outOfService }); + expect(lastPayload()).toMatchObject({ Id: 'u1', Type: '8', RespondingTo: '1234', RespondingToType: DestinationEntityType.Call }); + + await useUnitActionsStore.getState().submitStatus({ status: responding }); + expect(lastPayload()).toMatchObject({ Type: '3', RespondingTo: '1234', RespondingToType: DestinationEntityType.Call }); + }); + + it('starts a fresh session and form when opened for another unit, dropping the previous call context', () => { + const store = useUnitActionsStore.getState(); + store.openActions(unit('u1'), { callContext: call }); + const firstSession = useUnitActionsStore.getState().actionsSessionId; + + useUnitActionsStore.getState().openActions(unit('u2')); + const reopened = useUnitActionsStore.getState(); + + expect(reopened.actionsSessionId).toBe(firstSession + 1); + expect(reopened.selectedUnit?.UnitId).toBe('u2'); + expect(reopened.callContext).toBeNull(); + expect(reopened.statusDestinationType).toBe('none'); + expect(reopened.statusSelectedCall).toBeNull(); + }); + + it('clears the call context when the actions are closed', () => { + useUnitActionsStore.getState().openActions(unit('u1'), { callContext: call }); + useUnitActionsStore.getState().closeActions(); + + expect(useUnitActionsStore.getState().isActionsOpen).toBe(false); + expect(useUnitActionsStore.getState().callContext).toBeNull(); + }); + + it('keeps the form (including the destination) when the save fails', async () => { + mockSaveUnitStatus.mockRejectedValueOnce(new Error('offline')); + useUnitActionsStore.getState().openActions(unit('u1')); + useUnitActionsStore.getState().setStatusSelectedCall(call); + useUnitActionsStore.getState().setSelectedStatus(responding); + + await expect(useUnitActionsStore.getState().submitStatus()).resolves.toBe(false); + const state = useUnitActionsStore.getState(); + expect(state.statusError).toBe('offline'); + expect(state.selectedStatus).toBe(responding); + expect(state.statusSelectedCall).toBe(call); + }); +}); diff --git a/src/stores/dispatch/personnel-actions-store.ts b/src/stores/dispatch/personnel-actions-store.ts index f8e2c708..f8552c13 100644 --- a/src/stores/dispatch/personnel-actions-store.ts +++ b/src/stores/dispatch/personnel-actions-store.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { savePersonsStaffings } from '@/api/personnel/personnelStaffing'; import { savePersonsStatuses } from '@/api/personnel/personnelStatuses'; -import { DestinationEntityType, type DestinationSelectionType } from '@/lib/destination-helpers'; +import { type DestinationSelectionType, getStatusDestinationPayload } from '@/lib/destination-helpers'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { type GroupResultData } from '@/models/v4/groups/groupsResultData'; import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; @@ -12,6 +12,14 @@ import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData export type PersonnelActionTab = 'status' | 'staffing'; export type DestinationType = DestinationSelectionType; +export interface OpenPersonnelActionsOptions { + /** + * Explicit call context (the dashboard "+" set-status-for-call action). The destination is preset + * to this call and it is sent with the status even when the status's Detail does not list calls. + */ + callContext?: CallResultData | null; +} + interface PersonnelActionsState { // Panel visibility isActionsOpen: boolean; @@ -19,6 +27,14 @@ interface PersonnelActionsState { // Selected personnel selectedPersonnel: PersonnelInfoResultData | null; + // Explicit call context the panel was opened from (null for a plain personnel selection) + callContext: CallResultData | null; + + // Incremented on every openActions so the panel re-initialises its default destination per open + actionsSessionId: number; + // The session whose default destination has been applied (kept here so a remount does not re-apply it) + destinationInitializedSessionId: number | null; + // Current tab activeTab: PersonnelActionTab; @@ -49,8 +65,9 @@ interface PersonnelActionsState { staffingError: string | null; // Actions - openActions: (personnel: PersonnelInfoResultData) => void; + openActions: (personnel: PersonnelInfoResultData, options?: OpenPersonnelActionsOptions) => void; closeActions: () => void; + markDestinationInitialized: (sessionId: number) => void; setActiveTab: (tab: PersonnelActionTab) => void; // Status actions @@ -84,6 +101,9 @@ interface PersonnelActionsState { const initialState = { isActionsOpen: false, selectedPersonnel: null, + callContext: null, + actionsSessionId: 0, + destinationInitializedSessionId: null, activeTab: 'status' as PersonnelActionTab, selectedStatus: null, statusDestinationType: 'none' as DestinationType, @@ -108,15 +128,18 @@ const initialState = { export const usePersonnelActionsStore = create((set, get) => ({ ...initialState, - openActions: (personnel) => { - set({ + openActions: (personnel, options) => { + const callContext = options?.callContext ?? null; + set((state) => ({ isActionsOpen: true, selectedPersonnel: personnel, + callContext, + actionsSessionId: state.actionsSessionId + 1, activeTab: 'status', - // Reset form states when opening for new personnel + // Reset form states when opening for new personnel; an explicit call context presets the destination selectedStatus: null, - statusDestinationType: 'none', - statusSelectedCall: null, + statusDestinationType: callContext ? 'call' : 'none', + statusSelectedCall: callContext, statusSelectedStation: null, statusSelectedPoi: null, statusNote: '', @@ -124,16 +147,19 @@ export const usePersonnelActionsStore = create((set, get) staffingNote: '', statusError: null, staffingError: null, - }); + })); }, closeActions: () => { set({ isActionsOpen: false, selectedPersonnel: null, + callContext: null, }); }, + markDestinationInitialized: (sessionId) => set({ destinationInitializedSessionId: sessionId }), + setActiveTab: (tab) => set({ activeTab: tab }), // Status actions @@ -189,7 +215,7 @@ export const usePersonnelActionsStore = create((set, get) const storeState = get(); const selectedPersonnel = overrides?.personnel ?? storeState.selectedPersonnel; const selectedStatus = overrides?.status ?? storeState.selectedStatus; - const { statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, statusNote } = storeState; + const { statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, statusNote, callContext } = storeState; if (!selectedPersonnel || !selectedStatus) { set({ statusError: 'Please select a status' }); @@ -200,25 +226,17 @@ export const usePersonnelActionsStore = create((set, get) try { const date = new Date(); - let respondingTo = ''; - - if (statusDestinationType === 'call' && statusSelectedCall) { - respondingTo = statusSelectedCall.CallId; - } else if (statusDestinationType === 'station' && statusSelectedStation) { - respondingTo = statusSelectedStation.GroupId; - } else if (statusDestinationType === 'poi' && statusSelectedPoi) { - respondingTo = statusSelectedPoi.PoiId.toString(); - } - - let respondingToType: number | null = null; - - if (statusDestinationType === 'call' && statusSelectedCall) { - respondingToType = DestinationEntityType.Call; - } else if (statusDestinationType === 'station' && statusSelectedStation) { - respondingToType = DestinationEntityType.Station; - } else if (statusDestinationType === 'poi' && statusSelectedPoi) { - respondingToType = DestinationEntityType.Poi; - } + // Only send a destination the chosen status supports (an explicit call context always may carry its call) + const { respondingTo, respondingToType } = getStatusDestinationPayload( + { + selectedDestinationType: statusDestinationType, + selectedCall: statusSelectedCall, + selectedStation: statusSelectedStation, + selectedPoi: statusSelectedPoi, + }, + selectedStatus.Detail, + !!callContext + ); await savePersonsStatuses({ UserIds: [selectedPersonnel.UserId], @@ -238,14 +256,11 @@ export const usePersonnelActionsStore = create((set, get) EventId: '', }); - // Reset the status form after successful submission + // Reset the status and note after a successful submission. The destination stays sticky so a + // follow-up status for the same person (e.g. Responding -> On Scene) keeps the same call. set({ isSubmittingStatus: false, selectedStatus: null, - statusDestinationType: 'none', - statusSelectedCall: null, - statusSelectedStation: null, - statusSelectedPoi: null, statusNote: '', }); diff --git a/src/stores/dispatch/unit-actions-store.ts b/src/stores/dispatch/unit-actions-store.ts index f78fb2ef..663c3439 100644 --- a/src/stores/dispatch/unit-actions-store.ts +++ b/src/stores/dispatch/unit-actions-store.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { saveUnitStatus } from '@/api/units/unitStatuses'; -import { DestinationEntityType, type DestinationSelectionType } from '@/lib/destination-helpers'; +import { type DestinationSelectionType, getStatusDestinationPayload } from '@/lib/destination-helpers'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { type GroupResultData } from '@/models/v4/groups/groupsResultData'; import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; @@ -11,6 +11,14 @@ import { SaveUnitStatusInput } from '@/models/v4/unitStatus/saveUnitStatusInput' export type DestinationType = DestinationSelectionType; +export interface OpenUnitActionsOptions { + /** + * Explicit call context (the dashboard "+" set-status-for-call action). The destination is preset + * to this call and it is sent with the status even when the status's Detail does not list calls. + */ + callContext?: CallResultData | null; +} + interface UnitActionsState { // Panel visibility isActionsOpen: boolean; @@ -18,6 +26,14 @@ interface UnitActionsState { // Selected unit selectedUnit: UnitInfoResultData | null; + // Explicit call context the panel was opened from (null for a plain unit selection) + callContext: CallResultData | null; + + // Incremented on every openActions so the panel re-initialises its default destination per open + actionsSessionId: number; + // The session whose default destination has been applied (kept here so a remount does not re-apply it) + destinationInitializedSessionId: number | null; + // Status action state selectedStatus: StatusesResultData | null; statusDestinationType: DestinationType; @@ -38,8 +54,9 @@ interface UnitActionsState { statusError: string | null; // Actions - openActions: (unit: UnitInfoResultData) => void; + openActions: (unit: UnitInfoResultData, options?: OpenUnitActionsOptions) => void; closeActions: () => void; + markDestinationInitialized: (sessionId: number) => void; // Status actions setSelectedStatus: (status: StatusesResultData | null) => void; @@ -65,6 +82,9 @@ interface UnitActionsState { const initialState = { isActionsOpen: false, selectedUnit: null, + callContext: null, + actionsSessionId: 0, + destinationInitializedSessionId: null, selectedStatus: null, statusDestinationType: 'none' as DestinationType, statusSelectedCall: null, @@ -83,28 +103,34 @@ const initialState = { export const useUnitActionsStore = create((set, get) => ({ ...initialState, - openActions: (unit) => { - set({ + openActions: (unit, options) => { + const callContext = options?.callContext ?? null; + set((state) => ({ isActionsOpen: true, selectedUnit: unit, - // Reset form states when opening for new unit + callContext, + actionsSessionId: state.actionsSessionId + 1, + // Reset form states when opening for new unit; an explicit call context presets the destination selectedStatus: null, - statusDestinationType: 'none', - statusSelectedCall: null, + statusDestinationType: callContext ? 'call' : 'none', + statusSelectedCall: callContext, statusSelectedStation: null, statusSelectedPoi: null, statusNote: '', statusError: null, - }); + })); }, closeActions: () => { set({ isActionsOpen: false, selectedUnit: null, + callContext: null, }); }, + markDestinationInitialized: (sessionId) => set({ destinationInitializedSessionId: sessionId }), + // Status actions setSelectedStatus: (status) => set({ selectedStatus: status, statusError: null }), @@ -158,7 +184,7 @@ export const useUnitActionsStore = create((set, get) => ({ const storeState = get(); const selectedUnit = overrides?.unit ?? storeState.selectedUnit; const selectedStatus = overrides?.status ?? storeState.selectedStatus; - const { statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, statusNote } = storeState; + const { statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, statusNote, callContext } = storeState; if (!selectedUnit || !selectedStatus) { set({ statusError: 'Please select a status' }); @@ -169,19 +195,17 @@ export const useUnitActionsStore = create((set, get) => ({ try { const date = new Date(); - let respondingTo = ''; - let respondingToType: number | null = null; - - if (statusDestinationType === 'call' && statusSelectedCall) { - respondingTo = statusSelectedCall.CallId; - respondingToType = DestinationEntityType.Call; - } else if (statusDestinationType === 'station' && statusSelectedStation) { - respondingTo = statusSelectedStation.GroupId; - respondingToType = DestinationEntityType.Station; - } else if (statusDestinationType === 'poi' && statusSelectedPoi) { - respondingTo = statusSelectedPoi.PoiId.toString(); - respondingToType = DestinationEntityType.Poi; - } + // Only send a destination the chosen status supports (an explicit call context always may carry its call) + const { respondingTo, respondingToType } = getStatusDestinationPayload( + { + selectedDestinationType: statusDestinationType, + selectedCall: statusSelectedCall, + selectedStation: statusSelectedStation, + selectedPoi: statusSelectedPoi, + }, + selectedStatus.Detail, + !!callContext + ); const input = new SaveUnitStatusInput(); input.Id = selectedUnit.UnitId; @@ -204,14 +228,11 @@ export const useUnitActionsStore = create((set, get) => ({ await saveUnitStatus(input); - // Reset the status form after successful submission + // Reset the status and note after a successful submission. The destination stays sticky so a + // follow-up status for the same unit (e.g. Responding -> On Scene) keeps the same call. set({ isSubmittingStatus: false, selectedStatus: null, - statusDestinationType: 'none', - statusSelectedCall: null, - statusSelectedStation: null, - statusSelectedPoi: null, statusNote: '', }); diff --git a/src/stores/operations/__tests__/store.test.ts b/src/stores/operations/__tests__/store.test.ts index 5a9eec49..12a71608 100644 --- a/src/stores/operations/__tests__/store.test.ts +++ b/src/stores/operations/__tests__/store.test.ts @@ -6,6 +6,11 @@ jest.mock('@/api/operations/operations', () => ({ newTimeReport: jest.fn(), saveTimeEntries: jest.fn(), submitTimeReport: jest.fn(), + signTimeReport: jest.fn(), + approveTimeReport: jest.fn(), + getExpenses: jest.fn(), + saveExpense: jest.fn(), + deleteExpense: jest.fn(), getFieldCostAccess: jest.fn(), getResourceUsage: jest.fn(), addResourceUsage: jest.fn(), @@ -14,7 +19,9 @@ jest.mock('@/api/operations/operations', () => ({ buildF42: jest.fn(), validateCalOesMarsWorkItem: jest.fn(), })); -jest.mock('@/lib/operations/capabilities', () => ({ operationsCapabilities: { editTime: true, recordUsage: true, draftF42: true, homeRoute: '/', useActiveUnitId: () => null } })); +jest.mock('@/lib/operations/capabilities', () => ({ + operationsCapabilities: { editTime: true, recordUsage: true, recordExpenses: true, approveTime: true, draftF42: true, homeRoute: '/', useActiveUnitId: () => null }, +})); jest.mock('@/stores/auth/store', () => { const { create } = jest.requireActual('zustand'); return { __esModule: true, default: create(() => ({ userId: 'me' })) }; @@ -30,18 +37,41 @@ import useAuthStore from '@/stores/auth/store'; import { useOperationsStore } from '@/stores/operations/store'; const server = jest.mocked(api); -const deployment = { Id: 'dep-1', Name: 'LNU', Status: 2, FinanceMode: 1, CallId: 9, Units: [], Personnel: [{ Id: 'dp-1', UserId: 'me', Name: 'Me', IsActive: true }], Equipment: [] }; -const report = (status = 0, entries: unknown[] = []) => ({ Id: 'r-1', DeploymentId: 'dep-1', ReportNumber: 1, ReportDate: '2026-09-19T00:00:00', Status: status, Entries: entries }); +const deployment = { + Id: 'dep-1', + Name: 'LNU', + Status: 2, + FinanceMode: 1, + CallId: 9, + Currency: 'USD', + Units: [{ Id: 'du-1', UnitId: 12, UnitName: 'E-41', IsActive: true }], + Personnel: [{ Id: 'dp-1', UserId: 'me', Name: 'Me', DeploymentUnitId: 'du-1', IsActive: true }], + Equipment: [], + TimeAccess: { CanManage: false, CanApprove: true, PersonnelId: 'dp-1', CrewUnitIds: ['du-1'], WritableSubjectIds: ['dp-1', 'du-1'] }, +}; +const report = (status = 0, entries: unknown[] = [], patch: Record = {}) => ({ + Id: 'r-1', + DeploymentId: 'dep-1', + ReportNumber: 1, + ReportDate: '2026-09-19T00:00:00', + Scope: 1, + DeploymentUnitId: 'du-1', + CanAct: true, + Status: status, + Entries: entries, + ...patch, +}); +const answer = (data: unknown, errors: unknown[] = [], warnings: unknown[] = []) => ({ Data: data, Status: 'Ok', PageSize: 1, Errors: errors, Warnings: warnings }) as never; beforeEach(() => { jest.clearAllMocks(); - useOperationsStore.setState({ identity: null, access: null, deployment: null, reports: [], report: null, entries: [], dirty: false, issues: [], warnings: [], error: null }); + useOperationsStore.setState({ identity: null, access: null, deployment: null, reports: [], scope: null, report: null, entries: [], dirty: false, issues: [], warnings: [], expenses: [], error: null }); server.getDeploymentAccess.mockResolvedValue({ Enabled: true, CanManage: false, CanApproveTimeReports: false, ContractorBilling: false }); server.getFieldCostAccess.mockResolvedValue({ Enabled: true, CanViewInternalCosts: false, CanRecordUsage: true }); server.getCalOesMarsAccess.mockRejectedValue(new Error('no add-on')); server.getDeployment.mockResolvedValue(deployment as never); server.getTimeReports.mockResolvedValue([]); - server.newTimeReport.mockResolvedValue({ Data: report(), Status: 'Ok', PageSize: 1, Errors: [], Warnings: [] } as never); + server.newTimeReport.mockResolvedValue(answer(report())); }); it('loads access without letting an unavailable add-on hide the deployments, and drops state on identity change', async () => { @@ -57,18 +87,19 @@ it('loads access without letting an unavailable add-on hide the deployments, and useAuthStore.setState({ userId: 'me' }); }); -it('creates the day report on demand, refuses to save invalid entries locally and submits only a clean saved report', async () => { +it('opens the crew report for the scope on demand, refuses invalid entries locally and submits only a clean saved report', async () => { await useOperationsStore.getState().loadAccess(); await useOperationsStore.getState().open('dep-1'); - await useOperationsStore.getState().openReport('2026-09-19', false); + useOperationsStore.getState().setScope({ kind: 'crew', unitId: 'du-1' }, '2026-09-19'); expect(useOperationsStore.getState().report).toBeNull(); + await useOperationsStore.getState().openReport('2026-09-19', false); expect(server.newTimeReport).not.toHaveBeenCalled(); await useOperationsStore.getState().openReport('2026-09-19', true); - expect(server.newTimeReport).toHaveBeenCalledWith('dep-1', '2026-09-19'); + expect(server.newTimeReport).toHaveBeenCalledWith('dep-1', '2026-09-19', { deploymentUnitId: 'du-1', deploymentPersonnelId: null }); expect(useOperationsStore.getState().report?.Id).toBe('r-1'); - const bad = { ...newEntry({ type: 0, id: 'dp-1', label: 'Me' }, '2026-09-19', 0), EndTime: '2026-09-19T06:00:00' }; + const bad = { ...newEntry({ type: 0, id: 'dp-1', label: 'Me' }, '2026-09-19', 0), EndLocal: '2026-09-19T06:00' }; useOperationsStore.getState().setEntries([bad]); expect(await useOperationsStore.getState().save()).toBe(false); expect(server.saveTimeEntries).not.toHaveBeenCalled(); @@ -76,17 +107,56 @@ it('creates the day report on demand, refuses to save invalid entries locally an expect(await useOperationsStore.getState().submit()).toBe(false); const good = newEntry({ type: 0, id: 'dp-1', label: 'Me' }, '2026-09-19', 0); - server.saveTimeEntries.mockResolvedValue({ Data: report(0, [{ ...good, Id: 'e-1', Hours: 10 }]), Status: 'Ok', PageSize: 1, Errors: [], Warnings: [{ Code: 'long_day' }] } as never); + server.saveTimeEntries.mockResolvedValue(answer(report(0, [{ ...good, Id: 'e-1', Hours: 10 }]), [], [{ Code: 'break_rule' }])); useOperationsStore.getState().setEntries([good]); expect(await useOperationsStore.getState().save()).toBe(true); expect(server.saveTimeEntries).toHaveBeenCalledWith('r-1', [good]); expect(useOperationsStore.getState().dirty).toBe(false); - expect(useOperationsStore.getState().warnings).toEqual([{ Code: 'long_day' }]); + expect(useOperationsStore.getState().warnings).toEqual([{ Code: 'break_rule' }]); + + server.signTimeReport.mockResolvedValue(answer(report(0, [{ ...good, Id: 'e-1' }], { ContractorSignedOn: '2026-09-19T20:00:00Z' }))); + expect(await useOperationsStore.getState().sign(true)).toBe(true); + expect(server.signTimeReport).toHaveBeenCalledWith('r-1', true, undefined); + expect(useOperationsStore.getState().report?.ContractorSignedOn).toBe('2026-09-19T20:00:00Z'); - server.submitTimeReport.mockResolvedValue({ Data: report(1, [{ ...good, Id: 'e-1' }]), Status: 'Ok', PageSize: 1, Errors: [], Warnings: [] } as never); + server.submitTimeReport.mockResolvedValue(answer(report(1, [{ ...good, Id: 'e-1' }]))); expect(await useOperationsStore.getState().submit()).toBe(true); expect(useOperationsStore.getState().report?.Status).toBe(1); - expect(useOperationsStore.getState().reports[0]?.Status).toBe(1); + expect(useOperationsStore.getState().reports.find((candidate) => candidate.Id === 'r-1')?.Status).toBe(1); + + server.approveTimeReport.mockResolvedValue(answer(report(2, [{ ...good, Id: 'e-1' }]))); + expect(await useOperationsStore.getState().approve('r-1')).toBe(true); + expect(useOperationsStore.getState().report?.Status).toBe(2); +}); + +it('keeps validation refusals from the server on the report and switches scope without leaking entries', async () => { + server.getTimeReports.mockResolvedValue([report(0, [{ Id: 'e-1', SubjectType: 0, DeploymentPersonnelId: 'dp-1', EntryType: 0, StartLocal: '2026-09-19T08:00', EndLocal: '2026-09-19T18:00' }])] as never); + await useOperationsStore.getState().open('dep-1'); + useOperationsStore.getState().setScope({ kind: 'crew', unitId: 'du-1' }, '2026-09-19'); + expect(useOperationsStore.getState().entries).toHaveLength(1); + + server.saveTimeEntries.mockResolvedValue(answer(report(0, []), [{ Code: 'subject_on_other_report', SubjectId: 'dp-1' }])); + useOperationsStore.getState().setEntries([...useOperationsStore.getState().entries]); + expect(await useOperationsStore.getState().save()).toBe(false); + expect(useOperationsStore.getState().issues).toEqual([{ Code: 'subject_on_other_report', SubjectId: 'dp-1' }]); + + useOperationsStore.getState().setScope({ kind: 'individual', personnelId: 'dp-1' }, '2026-09-19'); + expect(useOperationsStore.getState().report).toBeNull(); + expect(useOperationsStore.getState().entries).toEqual([]); + expect(useOperationsStore.getState().issues).toEqual([]); +}); + +it('files expenses against the open deployment and removes only by id', async () => { + await useOperationsStore.getState().open('dep-1'); + server.saveExpense.mockResolvedValue({ Id: 'x-1', DeploymentId: 'dep-1', ExpenseDate: '2026-09-19T00:00:00', ExpenseType: 4, Amount: 88.4, PreApproved: false, Billable: true } as never); + expect(await useOperationsStore.getState().addExpense({ ExpenseDate: '2026-09-19', ExpenseType: 4, Amount: 88.4, ReceiptData: 'AAAA', ReceiptFileName: 'r.jpg', ReceiptContentType: 'image/jpeg' })).toBe(true); + expect(server.saveExpense).toHaveBeenCalledWith(expect.objectContaining({ DeploymentId: 'dep-1', ExpenseType: 4, ReceiptData: 'AAAA' })); + expect(useOperationsStore.getState().expenses.map((expense) => expense.Id)).toEqual(['x-1']); + + server.deleteExpense.mockResolvedValue({} as never); + expect(await useOperationsStore.getState().removeExpense('x-1')).toBe(true); + expect(server.deleteExpense).toHaveBeenCalledWith('x-1'); + expect(useOperationsStore.getState().expenses).toEqual([]); }); it('stamps usage readings with the open deployment and surfaces server refusals as reason codes', async () => { diff --git a/src/stores/operations/store.ts b/src/stores/operations/store.ts index bf89c85f..ccfc4ca7 100644 --- a/src/stores/operations/store.ts +++ b/src/stores/operations/store.ts @@ -2,42 +2,50 @@ import { create } from 'zustand'; import { addResourceUsage, + approveTimeReport, buildF42, + deleteExpense, getCalOesMarsAccess, getCalOesMarsQueue, getDeployment, getDeploymentAccess, getDeployments, + getExpenses, getFieldCostAccess, getResourceUsage, getTimeReports, newTimeReport, + saveExpense, saveTimeEntries, + signTimeReport, submitTimeReport, validateCalOesMarsWorkItem, } from '@/api/operations/operations'; import { operationsCapabilities } from '@/lib/operations/capabilities'; -import { operationsError, reportForDate, validateEntries } from '@/lib/operations/time'; +import { operationsError, reportForScope, scopeKey, type TimeScope, validateEntries } from '@/lib/operations/time'; import type { CalOesMarsAccess, CalOesMarsQueueItem, CalOesMarsValidation, Deployment, DeploymentAccess, + Expense, + ExpenseInput, FieldCostAccess, ResourceUsage, ResourceUsageInput, TimeEntry, TimeReport, TimeReportIssue, + TimeReportResponse, } from '@/models/v4/operations'; import useAuthStore from '@/stores/auth/store'; import { securityStore } from '@/stores/security/store'; -// One store for the field-operations surface: the deployment list, the open deployment with its -// daily time reports, resource usage readings and the CAL OES MARS work items that belong to it. -// Everything shown came from the server's own scoping (a rostered member sees their deployments, -// a manager sees the department's); this store never decides what the person may see. +// One store for the field-operations surface: the deployment list, the open deployment with its crew and +// individual time reports, expenses, resource usage readings and the CAL OES MARS work items that belong to +// it. Everything shown came from the server's own scoping (a rostered member or the seated crew of a deployed +// unit sees their deployments, a manager the department's); this store never decides what the person may see. export interface OperationsState { identity: string | null; @@ -48,11 +56,13 @@ export interface OperationsState { includeClosed: boolean; deployment: Deployment | null; reports: TimeReport[]; + scope: TimeScope | null; report: TimeReport | null; entries: TimeEntry[]; dirty: boolean; issues: TimeReportIssue[]; warnings: TimeReportIssue[]; + expenses: Expense[]; usage: ResourceUsage[]; marsItems: CalOesMarsQueueItem[]; validation: CalOesMarsValidation | null; @@ -61,10 +71,17 @@ export interface OperationsState { loadAccess: () => Promise; loadDeployments: (includeClosed?: boolean) => Promise; open: (id: string) => Promise; + setScope: (scope: TimeScope | null, dateKey: string) => void; openReport: (dateKey: string, create: boolean) => Promise; + selectReport: (reportId: string) => void; setEntries: (entries: TimeEntry[]) => void; save: () => Promise; submit: () => Promise; + sign: (crewBossSigned: boolean, customerSignerName?: string | null) => Promise; + approve: (reportId: string) => Promise; + loadExpenses: () => Promise; + addExpense: (input: Omit) => Promise; + removeExpense: (id: string) => Promise; loadUsage: () => Promise; addUsage: (input: Omit) => Promise; loadMars: () => Promise; @@ -73,23 +90,29 @@ export interface OperationsState { close: () => void; } -const initial = { - identity: null as string | null, - access: null, - costAccess: null, - marsAccess: null, - deployments: [], - includeClosed: false, +const closed = { deployment: null, reports: [], + scope: null, report: null, entries: [], dirty: false, issues: [], warnings: [], + expenses: [], usage: [], marsItems: [], validation: null, +}; + +const initial = { + ...closed, + identity: null as string | null, + access: null, + costAccess: null, + marsAccess: null, + deployments: [], + includeClosed: false, busy: false, error: null, }; @@ -114,128 +137,175 @@ const currentIdentity = (): string | null => { return userId && departmentId ? `${userId}:${departmentId}` : null; }; -export const useOperationsStore = create()((set, get) => ({ - ...initial, - loadAccess: async () => { - const identity = currentIdentity(); - if (get().identity !== identity) set({ ...initial, identity }); - await settle(async () => { - const access = await getDeploymentAccess(); - // Usage and MARS access are optional add-ons; a failure there must not hide the deployments. - const [costAccess, marsAccess] = await Promise.all([ - operationsCapabilities.recordUsage ? getFieldCostAccess().catch(() => null) : Promise.resolve(null), - operationsCapabilities.draftF42 ? getCalOesMarsAccess().catch(() => null) : Promise.resolve(null), - ]); - set({ access, costAccess, marsAccess }); - }); - }, - loadDeployments: async (includeClosed = get().includeClosed) => { - await settle(async () => { - const deployments = await getDeployments(!includeClosed); - set({ deployments, includeClosed }); - }); - }, - open: async (id) => { - await settle(async () => { - const deployment = await getDeployment(id); - const reports = await getTimeReports(id); - set({ deployment, reports, report: null, entries: [], dirty: false, issues: [], warnings: [], usage: [], marsItems: [], validation: null }); - }); - }, - openReport: async (dateKey, create) => { - const deployment = get().deployment; - if (!deployment) return; - await settle(async () => { - let report = reportForDate(get().reports, dateKey); - if (!report && create && operationsCapabilities.editTime) { - const response = await newTimeReport(deployment.Id, dateKey); - report = response.Data; - set({ reports: [...get().reports, report] }); - } - set({ report, entries: report ? report.Entries.map((entry) => ({ ...entry })) : [], dirty: false, issues: [], warnings: [] }); - }); - }, - setEntries: (entries) => set({ entries, dirty: true, issues: [] }), - save: async () => { - const report = get().report; - if (!report) return false; - const local = validateEntries(get().entries); - if (local.length > 0) { - set({ issues: local.map((issue) => ({ Code: issue.code, EntryId: String(issue.index) })) }); +const copyEntries = (report: TimeReport | null) => (report ? report.Entries.map((entry) => ({ ...entry })) : []); + +export const useOperationsStore = create()((set, get) => { + /** + * Adopt a report the server just answered with: refresh it in the day list and, when it is open, in the editor. + * A validation refusal comes back with the stored report; keep the person's unsaved edits and show the issues. + */ + const adopt = (response: TimeReportResponse) => { + const errors = response.Errors ?? []; + if (errors.length > 0) { + set({ issues: errors, warnings: response.Warnings ?? [] }); return false; } - const saved = await settle(async () => { - const response = await saveTimeEntries(report.Id, get().entries); - const next = response.Data; - set({ - report: next, - entries: next.Entries.map((entry) => ({ ...entry })), - dirty: false, - issues: response.Errors ?? [], - warnings: response.Warnings ?? [], - reports: get().reports.map((existing) => (existing.Id === next.Id ? next : existing)), - }); - return (response.Errors ?? []).length === 0; + const next = response.Data; + const open = get().report?.Id === next?.Id; + set({ + reports: next ? [...get().reports.filter((existing) => existing.Id !== next.Id), next] : get().reports, + ...(open || !get().report ? { report: next, entries: copyEntries(next), dirty: false } : {}), + issues: [], + warnings: response.Warnings ?? [], }); - return saved === true; - }, - submit: async () => { - const report = get().report; - if (!report || get().dirty) return false; - const submitted = await settle(async () => { - const response = await submitTimeReport(report.Id); - const next = response.Data; - set({ - report: next, - entries: next.Entries.map((entry) => ({ ...entry })), - issues: response.Errors ?? [], - warnings: response.Warnings ?? [], - reports: get().reports.map((existing) => (existing.Id === next.Id ? next : existing)), + return true; + }; + + return { + ...initial, + loadAccess: async () => { + const identity = currentIdentity(); + if (get().identity !== identity) set({ ...initial, identity }); + await settle(async () => { + const access = await getDeploymentAccess(); + // Usage and MARS access are optional add-ons; a failure there must not hide the deployments. + const [costAccess, marsAccess] = await Promise.all([ + operationsCapabilities.recordUsage ? getFieldCostAccess().catch(() => null) : Promise.resolve(null), + operationsCapabilities.draftF42 ? getCalOesMarsAccess().catch(() => null) : Promise.resolve(null), + ]); + set({ access, costAccess, marsAccess }); }); - return (response.Errors ?? []).length === 0; - }); - return submitted === true; - }, - loadUsage: async () => { - const deployment = get().deployment; - if (!deployment || !get().costAccess?.Enabled) return; - await settle(async () => set({ usage: await getResourceUsage(deployment.Id) })); - }, - addUsage: async (input) => { - const deployment = get().deployment; - if (!deployment) return false; - const added = await settle(async () => { - const reading = await addResourceUsage({ ...input, DeploymentId: deployment.Id, CallId: deployment.CallId ?? null }); - set({ usage: [reading, ...get().usage] }); - return true; - }); - return added === true; - }, - loadMars: async () => { - const deployment = get().deployment; - if (!deployment || !get().marsAccess?.Enabled) return; - await settle(async () => { - const queue = await getCalOesMarsQueue(); - set({ marsItems: queue.filter((item) => item.DeploymentId === deployment.Id) }); - }); - }, - draftF42: async () => { - const deployment = get().deployment; - if (!deployment) return false; - const built = await settle(async () => { - await buildF42(deployment.Id); - set({ marsItems: (await getCalOesMarsQueue()).filter((item) => item.DeploymentId === deployment.Id) }); - return true; - }); - return built === true; - }, - validate: async (workItemId) => { - const validated = await settle(async () => { - const validation = await validateCalOesMarsWorkItem(workItemId); - set({ validation }); - return validation.IsReadyForPortal; - }); - return validated === true; - }, - close: () => set({ deployment: null, reports: [], report: null, entries: [], dirty: false, issues: [], warnings: [], usage: [], marsItems: [], validation: null, error: null }), -})); + }, + loadDeployments: async (includeClosed = get().includeClosed) => { + await settle(async () => { + const deployments = await getDeployments(!includeClosed); + set({ deployments, includeClosed }); + }); + }, + open: async (id) => { + await settle(async () => { + const [deployment, reports] = await Promise.all([getDeployment(id), getTimeReports(id)]); + set({ ...closed, deployment, reports }); + }); + }, + setScope: (scope, dateKey) => { + const report = reportForScope(get().reports, dateKey, scope); + set({ scope, report, entries: copyEntries(report), dirty: false, issues: [], warnings: [] }); + }, + openReport: async (dateKey, create) => { + const { deployment, scope } = get(); + if (!deployment) return; + let report = reportForScope(get().reports, dateKey, scope); + if (!report && create && scope && operationsCapabilities.editTime) { + await settle(async () => { + const response = await newTimeReport(deployment.Id, dateKey, { + deploymentUnitId: scope.kind === 'crew' ? scope.unitId : null, + deploymentPersonnelId: scope.kind === 'individual' ? scope.personnelId : null, + }); + report = response.Data; + set({ reports: [...get().reports, report] }); + }); + } + // The scope can change while the create is in flight; only the report that still matches it opens. + if (scopeKey(get().scope) !== scopeKey(scope)) return; + set({ report, entries: copyEntries(report), dirty: false, issues: [], warnings: [] }); + }, + selectReport: (reportId) => { + const report = get().reports.find((candidate) => candidate.Id === reportId) ?? null; + set({ report, entries: copyEntries(report), dirty: false, issues: [], warnings: [] }); + }, + setEntries: (entries) => set({ entries, dirty: true, issues: [] }), + save: async () => { + const report = get().report; + if (!report) return false; + const local = validateEntries(get().entries); + if (local.length > 0) { + set({ issues: local.map((issue) => ({ Code: issue.code, EntryId: String(issue.index) })) }); + return false; + } + const saved = await settle(async () => adopt(await saveTimeEntries(report.Id, get().entries))); + return saved === true; + }, + submit: async () => { + const report = get().report; + if (!report || get().dirty) return false; + const submitted = await settle(async () => adopt(await submitTimeReport(report.Id))); + return submitted === true; + }, + sign: async (crewBossSigned, customerSignerName) => { + const report = get().report; + if (!report || (!crewBossSigned && !customerSignerName)) return false; + const signed = await settle(async () => adopt(await signTimeReport(report.Id, crewBossSigned, customerSignerName))); + return signed === true; + }, + approve: async (reportId) => { + const approved = await settle(async () => adopt(await approveTimeReport(reportId))); + return approved === true; + }, + loadExpenses: async () => { + const deployment = get().deployment; + if (!deployment || !operationsCapabilities.recordExpenses) return; + await settle(async () => set({ expenses: await getExpenses(deployment.Id) })); + }, + addExpense: async (input) => { + const deployment = get().deployment; + if (!deployment) return false; + const added = await settle(async () => { + const expense = await saveExpense({ ...input, DeploymentId: deployment.Id }); + set({ expenses: [expense, ...get().expenses.filter((existing) => existing.Id !== expense.Id)] }); + return true; + }); + return added === true; + }, + removeExpense: async (id) => { + const removed = await settle(async () => { + await deleteExpense(id); + set({ expenses: get().expenses.filter((expense) => expense.Id !== id) }); + return true; + }); + return removed === true; + }, + loadUsage: async () => { + const deployment = get().deployment; + if (!deployment || !get().costAccess?.Enabled) return; + await settle(async () => set({ usage: await getResourceUsage(deployment.Id) })); + }, + addUsage: async (input) => { + const deployment = get().deployment; + if (!deployment) return false; + const added = await settle(async () => { + const reading = await addResourceUsage({ ...input, DeploymentId: deployment.Id, CallId: deployment.CallId ?? null }); + set({ usage: [reading, ...get().usage] }); + return true; + }); + return added === true; + }, + loadMars: async () => { + const deployment = get().deployment; + if (!deployment || !get().marsAccess?.Enabled) return; + await settle(async () => { + const queue = await getCalOesMarsQueue(); + set({ marsItems: queue.filter((item) => item.DeploymentId === deployment.Id) }); + }); + }, + draftF42: async () => { + const deployment = get().deployment; + if (!deployment) return false; + const built = await settle(async () => { + await buildF42(deployment.Id); + set({ marsItems: (await getCalOesMarsQueue()).filter((item) => item.DeploymentId === deployment.Id) }); + return true; + }); + return built === true; + }, + validate: async (workItemId) => { + const validated = await settle(async () => { + const validation = await validateCalOesMarsWorkItem(workItemId); + set({ validation }); + return validation.IsReadyForPortal; + }); + return validated === true; + }, + close: () => set({ ...closed, error: null }), + }; +}); diff --git a/src/translations/ar.json b/src/translations/ar.json index 0d4d8c92..3384cb55 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -163,6 +163,12 @@ "no_location_for_routing": "لا توجد بيانات موقع متاحة للتوجيه", "no_protocols": "لا توجد بروتوكولات مرفقة بهذه المكالمة", "no_timeline": "لا توجد أحداث زمنية متاحة", + "activity_link": { + "auto_linked": "مرتبط تلقائيًا", + "auto_linked_hint": "أُرسلت دون مكالمة؛ ربطها Resgrid بالمكالمة من الحالة السابقة أو من الإرسال.", + "inferred": "مُستنتَج", + "inferred_hint": "حدّدتها دون وجهة وحدةٌ أو شخصٌ مُرسَل إلى هذه المكالمة أثناء العمل عليها." + }, "not_available": "غير متوفر", "not_found": "لم يتم العثور على المكالمة", "missing_call_id": "معرّف المكالمة مفقود", @@ -678,7 +684,13 @@ "empty": "لم يتم إرفاق أي ملفات بهذه الجهة.", "share_unavailable": "المشاركة غير متاحة على هذا الجهاز.", "download_failed": "تعذر تنزيل الملف." - } + }, + "withheld": "تم إخفاء {{count}} من التفاصيل المحمية.", + "categoryLabel": "الفئة: {{name}}", + "physicalAddress": "العنوان الفعلي", + "mailingAddress": "العنوان البريدي", + "hasGeofence": "لهذا الموقع سياج جغرافي مرسوم على الخريطة.", + "customFields": "حقول مخصصة" }, "dispatch": { "active_calls": "المكالمات النشطة", @@ -1141,7 +1153,15 @@ "disabled": "هذه الميزة معطّلة لقسمك.", "locked": "افتح البيانات المحمية للمتابعة.", "retry": "تعذر الوصول إلى الخادم. حاول مرة أخرى.", - "validation": "رفض الخادم التغيير." + "validation": "رفض الخادم التغيير.", + "timereports_subject_covered": "لهذا الطاقم أو الشخص وقت مسجل بالفعل في تقرير آخر لهذا اليوم.", + "timereports_date_exists": "يوجد بالفعل تقرير لهذا الطاقم أو الشخص في ذلك اليوم.", + "timereports_scope_invalid": "هذا الطاقم أو الشخص غير نشط في عملية النشر هذه.", + "timereports_locked": "لم يعد بالإمكان تغيير هذا التقرير.", + "timereports_time_invalid": "أدخل الأوقات بصيغة HH:mm.", + "deployments_closed": "عملية النشر هذه مغلقة.", + "expenses_receipt_invalid": "تعذرت قراءة الإيصال.", + "deployments_attachment_too_large": "الإيصال كبير جدًا." }, "financeMode": { "0": "تشغيلي", @@ -1152,7 +1172,15 @@ "end_before_start": "يجب أن يكون وقت الانتهاء بعد وقت البدء.", "missing_subject": "اختر لمن أو لماذا هذا الإدخال.", "missing_time": "أدخل وقت البدء والانتهاء.", - "overlap": "يتداخل هذا الإدخال مع إدخال آخر لنفس الشخص أو الوحدة." + "overlap": "يتداخل هذا الإدخال مع إدخال آخر لنفس الشخص أو الوحدة.", + "subject_outside_report_scope": "هذا القيد يخص شخصًا خارج طاقم هذا التقرير أو الشخص المعني.", + "subject_on_other_report": "لهذا الشخص أو الوحدة وقت مسجل بالفعل في تقرير آخر لنفس اليوم.", + "subject_not_on_roster": "هذا القيد يخص شخصًا غير موجود في القائمة.", + "entries_overlap": "يتداخل قيدان للشخص أو الوحدة نفسها.", + "no_entries": "أضف قيدًا واحدًا على الأقل.", + "break_rule": "لا توجد استراحة مسجلة لمناوبة طويلة.", + "long_travel": "وقت السفر أكثر من المسموح في يوم واحد ويحتاج إلى اعتماد.", + "outside_report_date": "هذا القيد يقع خارج تاريخ التقرير." }, "mars": { "counts": "{{errors}} أخطاء · {{warnings}} تحذيرات", @@ -1201,7 +1229,7 @@ "noReport": "لا يوجد تقرير وقت لهذا اليوم.", "notes": "ملاحظات", "remove": "إزالة", - "report": "تقرير رقم {{number}}", + "report": "التقرير #{{number}}", "save": "حفظ الإدخالات", "start": "بدء تقرير الوقت", "subject": "من أو ماذا", @@ -1210,7 +1238,19 @@ "to": "إلى", "total": "الإجمالي {{hours}} س", "type": "نوع الإدخال", - "unpaidBreak": "استراحة غير مدفوعة (دقيقة)" + "unpaidBreak": "استراحة غير مدفوعة (دقيقة)", + "crewReport": "تقرير وقت الطاقم #{{number}} · {{name}}", + "individualReport": "تقرير الوقت #{{number}} · {{name}}", + "noCrewReport": "لا يوجد تقرير وقت طاقم لـ {{name}} في هذا اليوم.", + "coveredBy": "هذا الوقت مسجل بالفعل في التقرير #{{number}} لهذا اليوم.", + "startCrew": "بدء تقرير وقت الطاقم", + "readOnly": "يمكنك عرض هذا التقرير لكن لا يمكنك تغييره.", + "nextDay": "ينتهي في اليوم التالي", + "applyToCrew": "تطبيق على الطاقم", + "approve": "اعتماد التقرير", + "dayReports": "تقارير هذا اليوم", + "awaitingApproval": "بانتظار الاعتماد ({{count}})", + "notOnRoster": "أنت لست ضمن قائمة أفراد أو طاقم عملية النشر هذه." }, "title": "عمليات النشر", "usage": { @@ -1224,6 +1264,54 @@ "startOdometer": "عداد المسافة الابتدائي", "title": "الاستخدام", "unit": "الوحدة" + }, + "crewOf": "طاقم {{names}}", + "scope": { + "deployment": "عملية النشر بالكامل", + "mine": "وقتي", + "crew": "الطاقم: {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "التوقيعات", + "crewBossSigned": "وقّع قائد الطاقم {{when}}", + "crewBossPending": "لم يوقّع قائد الطاقم بعد.", + "customerSigned": "موقّع الجهة: {{name}}", + "customerPending": "لم يُسجل توقيع الجهة.", + "asCrewBoss": "التوقيع كقائد للطاقم", + "customerName": "اسم موقّع الجهة", + "recordCustomer": "تسجيل توقيع الجهة" + }, + "expenses": { + "title": "المصروفات", + "type": { + "0": "بدل الوجبات اليومي", + "1": "السكن", + "2": "سكن خاص", + "3": "العبّارة", + "4": "الوقود", + "5": "إعادة تزويد المستلزمات", + "6": "أخرى" + }, + "typeLabel": "نوع المصروف", + "amount": "المبلغ ({{currency}})", + "description": "الوصف", + "city": "المدينة", + "takePhoto": "التقاط صورة", + "choosePhoto": "اختيار صورة", + "receiptAttached": "تم إرفاق صورة الإيصال.", + "save": "إضافة مصروف", + "add": "مصروف جديد", + "noneToday": "لا توجد مصروفات لهذا اليوم.", + "otherDays": "أيام أخرى", + "hasReceipt": "إيصال", + "remove": "إزالة", + "removeTitle": "إزالة المصروف؟", + "removeBody": "سيؤدي هذا إلى إزالة المصروف وإيصاله من عملية النشر.", + "cancel": "إلغاء", + "saved": "تم حفظ المصروف.", + "amountInvalid": "أدخل مبلغًا مثل 42.50.", + "photoDenied": "اسمح بالوصول إلى الكاميرا أو الصور لإرفاق إيصال." } }, "personnel": { @@ -1940,5 +2028,12 @@ "entrance": "المدخل", "alert_notes": "ملاحظات التنبيه", "files": "ملفات الموقع" + }, + "notifications": { + "detail_title": "إشعار", + "additional_info": "معلومات إضافية", + "view_call": "عرض المكالمة", + "view_chat": "عرض المحادثة", + "open_reference": "فتح" } } diff --git a/src/translations/de.json b/src/translations/de.json index 8e18caf0..5406a585 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -164,6 +164,12 @@ "no_location_for_routing": "Keine Standortdaten für die Routenführung verfügbar", "no_protocols": "Keine Protokolle diesem Einsatz zugeordnet", "no_timeline": "Keine Zeitverlaufsereignisse verfügbar", + "activity_link": { + "auto_linked": "automatisch verknüpft", + "auto_linked_hint": "Ohne Einsatz gesendet; Resgrid hat den Status anhand des vorherigen Status oder der Disposition verknüpft.", + "inferred": "abgeleitet", + "inferred_hint": "Ohne Ziel gesetzt von einer zu diesem Einsatz disponierten Einheit oder Person während der Bearbeitung." + }, "not_available": "k. A.", "not_found": "Einsatz nicht gefunden", "missing_call_id": "Einsatz-ID fehlt", @@ -678,7 +684,13 @@ "empty": "An diesen Kontakt wurden keine Dateien angehängt.", "share_unavailable": "Teilen ist auf diesem Gerät nicht verfügbar.", "download_failed": "Die Datei konnte nicht heruntergeladen werden." - } + }, + "withheld": "{{count}} geschützte Angaben sind ausgeblendet.", + "categoryLabel": "Kategorie: {{name}}", + "physicalAddress": "Anschrift", + "mailingAddress": "Postanschrift", + "hasGeofence": "Für diesen Ort ist ein Geofence hinterlegt.", + "customFields": "Benutzerdefinierte Felder" }, "dispatch": { "active_calls": "Aktive Einsätze", @@ -1141,7 +1153,15 @@ "disabled": "Diese Funktion ist für Ihre Abteilung deaktiviert.", "locked": "Geschützte Daten entsperren, um fortzufahren.", "retry": "Server nicht erreichbar. Bitte erneut versuchen.", - "validation": "Der Server hat die Änderung abgelehnt." + "validation": "Der Server hat die Änderung abgelehnt.", + "timereports_subject_covered": "Für diese Besatzung oder Person ist an diesem Tag bereits Zeit in einem anderen Bericht erfasst.", + "timereports_date_exists": "Für diese Besatzung oder Person gibt es an diesem Tag bereits einen Bericht.", + "timereports_scope_invalid": "Diese Besatzung oder Person ist in diesem Einsatz nicht aktiv.", + "timereports_locked": "Dieser Bericht kann nicht mehr geändert werden.", + "timereports_time_invalid": "Geben Sie Zeiten als HH:mm ein.", + "deployments_closed": "Dieser Einsatz ist abgeschlossen.", + "expenses_receipt_invalid": "Der Beleg konnte nicht gelesen werden.", + "deployments_attachment_too_large": "Der Beleg ist zu groß." }, "financeMode": { "0": "Operativ", @@ -1152,7 +1172,15 @@ "end_before_start": "Die Endzeit muss nach der Startzeit liegen.", "missing_subject": "Wählen Sie, für wen oder was dieser Eintrag gilt.", "missing_time": "Start- und Endzeit eingeben.", - "overlap": "Dieser Eintrag überschneidet sich mit einem anderen für dieselbe Person oder Einheit." + "overlap": "Dieser Eintrag überschneidet sich mit einem anderen für dieselbe Person oder Einheit.", + "subject_outside_report_scope": "Dieser Eintrag betrifft jemanden außerhalb der Besatzung oder Person dieses Berichts.", + "subject_on_other_report": "Für diese Person oder Einheit ist am selben Tag bereits Zeit in einem anderen Bericht erfasst.", + "subject_not_on_roster": "Dieser Eintrag betrifft jemanden, der nicht in der Besetzung steht.", + "entries_overlap": "Zwei Einträge derselben Person oder Einheit überschneiden sich.", + "no_entries": "Fügen Sie mindestens einen Eintrag hinzu.", + "break_rule": "Für eine lange Schicht ist keine Pause erfasst.", + "long_travel": "Mehr Reisezeit als an einem Tag erlaubt; sie muss genehmigt werden.", + "outside_report_date": "Dieser Eintrag liegt außerhalb des Berichtsdatums." }, "mars": { "counts": "{{errors}} Fehler · {{warnings}} Warnungen", @@ -1201,7 +1229,7 @@ "noReport": "Kein Zeitbericht für diesen Tag.", "notes": "Notizen", "remove": "Entfernen", - "report": "Bericht Nr. {{number}}", + "report": "Bericht #{{number}}", "save": "Einträge speichern", "start": "Zeitbericht beginnen", "subject": "Wer oder was", @@ -1210,7 +1238,19 @@ "to": "Bis", "total": "Gesamt {{hours}} h", "type": "Eintragsart", - "unpaidBreak": "Unbezahlte Pause (Min.)" + "unpaidBreak": "Unbezahlte Pause (Min.)", + "crewReport": "Besatzungszeitbericht #{{number}} · {{name}}", + "individualReport": "Zeitbericht #{{number}} · {{name}}", + "noCrewReport": "Kein Besatzungszeitbericht für {{name}} an diesem Tag.", + "coveredBy": "Diese Zeit steht an diesem Tag bereits in Bericht #{{number}}.", + "startCrew": "Besatzungszeitbericht beginnen", + "readOnly": "Sie können diesen Bericht ansehen, aber nicht ändern.", + "nextDay": "Endet am nächsten Tag", + "applyToCrew": "Auf Besatzung übertragen", + "approve": "Bericht genehmigen", + "dayReports": "Berichte an diesem Tag", + "awaitingApproval": "Wartet auf Genehmigung ({{count}})", + "notOnRoster": "Sie stehen nicht in der Besetzung oder Besatzung dieses Einsatzes." }, "title": "Einsätze", "usage": { @@ -1224,6 +1264,54 @@ "startOdometer": "Kilometerstand Start", "title": "Nutzung", "unit": "Einheit" + }, + "crewOf": "Besatzung von {{names}}", + "scope": { + "deployment": "Gesamter Einsatz", + "mine": "Meine Zeit", + "crew": "Besatzung: {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "Unterschriften", + "crewBossSigned": "Vom Besatzungsführer unterschrieben {{when}}", + "crewBossPending": "Noch nicht vom Besatzungsführer unterschrieben.", + "customerSigned": "Unterzeichner der Behörde: {{name}}", + "customerPending": "Keine Unterschrift der Behörde erfasst.", + "asCrewBoss": "Als Besatzungsführer unterschreiben", + "customerName": "Name des Unterzeichners der Behörde", + "recordCustomer": "Unterschrift der Behörde erfassen" + }, + "expenses": { + "title": "Ausgaben", + "type": { + "0": "Verpflegungspauschale", + "1": "Unterkunft", + "2": "Private Unterkunft", + "3": "Fähre", + "4": "Kraftstoff", + "5": "Materialnachschub", + "6": "Sonstiges" + }, + "typeLabel": "Ausgabenart", + "amount": "Betrag ({{currency}})", + "description": "Beschreibung", + "city": "Ort", + "takePhoto": "Foto aufnehmen", + "choosePhoto": "Foto auswählen", + "receiptAttached": "Belegfoto angehängt.", + "save": "Ausgabe hinzufügen", + "add": "Neue Ausgabe", + "noneToday": "Keine Ausgaben an diesem Tag.", + "otherDays": "Andere Tage", + "hasReceipt": "Beleg", + "remove": "Entfernen", + "removeTitle": "Ausgabe entfernen?", + "removeBody": "Die Ausgabe und ihr Beleg werden aus dem Einsatz entfernt.", + "cancel": "Abbrechen", + "saved": "Ausgabe gespeichert.", + "amountInvalid": "Geben Sie einen Betrag wie 42.50 ein.", + "photoDenied": "Erlauben Sie den Zugriff auf Kamera oder Fotos, um einen Beleg anzuhängen." } }, "personnel": { @@ -1940,5 +2028,12 @@ "entrance": "Zugang", "alert_notes": "Warnhinweise", "files": "Objektdateien" + }, + "notifications": { + "detail_title": "Benachrichtigung", + "additional_info": "Zusätzliche Informationen", + "view_call": "Einsatz anzeigen", + "view_chat": "Chat anzeigen", + "open_reference": "Öffnen" } } diff --git a/src/translations/el.json b/src/translations/el.json index 7c22abca..6ba29828 100644 --- a/src/translations/el.json +++ b/src/translations/el.json @@ -163,6 +163,12 @@ "no_location_for_routing": "Δεν υπάρχουν διαθέσιμα δεδομένα τοποθεσίας για δρομολόγηση", "no_protocols": "Δεν έχουν επισυναφθεί πρωτόκολλα σε αυτή την κλήση", "no_timeline": "Δεν υπάρχουν διαθέσιμα συμβάντα χρονολογίου", + "activity_link": { + "auto_linked": "αυτόματη σύνδεση", + "auto_linked_hint": "Στάλθηκε χωρίς κλήση· το Resgrid τη συνέδεσε από την προηγούμενη κατάσταση ή την αποστολή.", + "inferred": "συναγόμενο", + "inferred_hint": "Ορίστηκε χωρίς προορισμό από μονάδα ή άτομο που είχε σταλεί σε αυτή την κλήση, ενώ εργαζόταν σε αυτή." + }, "not_available": "Μ/Δ", "not_found": "Η κλήση δεν βρέθηκε", "missing_call_id": "Λείπει το αναγνωριστικό κλήσης", @@ -678,7 +684,13 @@ "empty": "Δεν έχουν επισυναφθεί αρχεία σε αυτή την επαφή.", "share_unavailable": "Η κοινοποίηση δεν είναι διαθέσιμη σε αυτή τη συσκευή.", "download_failed": "Δεν ήταν δυνατή η λήψη του αρχείου." - } + }, + "withheld": "{{count}} προστατευμένα στοιχεία είναι κρυφά.", + "categoryLabel": "Κατηγορία: {{name}}", + "physicalAddress": "Φυσική διεύθυνση", + "mailingAddress": "Ταχυδρομική διεύθυνση", + "hasGeofence": "Αυτή η τοποθεσία έχει χαρτογραφημένη γεωπεριοχή.", + "customFields": "Προσαρμοσμένα πεδία" }, "dispatch": { "active_calls": "Ενεργές Κλήσεις", @@ -1141,7 +1153,15 @@ "disabled": "Αυτή η λειτουργία είναι απενεργοποιημένη για το τμήμα σας.", "locked": "Ξεκλειδώστε τα προστατευμένα δεδομένα για να συνεχίσετε.", "retry": "Δεν ήταν δυνατή η σύνδεση με τον διακομιστή. Δοκιμάστε ξανά.", - "validation": "Ο διακομιστής απέρριψε την αλλαγή." + "validation": "Ο διακομιστής απέρριψε την αλλαγή.", + "timereports_subject_covered": "Αυτό το πλήρωμα ή πρόσωπο έχει ήδη χρόνο σε άλλη αναφορά για αυτή την ημέρα.", + "timereports_date_exists": "Υπάρχει ήδη αναφορά για αυτό το πλήρωμα ή πρόσωπο εκείνη την ημέρα.", + "timereports_scope_invalid": "Αυτό το πλήρωμα ή πρόσωπο δεν είναι ενεργό σε αυτή την ανάπτυξη.", + "timereports_locked": "Αυτή η αναφορά δεν μπορεί πλέον να αλλάξει.", + "timereports_time_invalid": "Εισαγάγετε ώρες ως HH:mm.", + "deployments_closed": "Αυτή η ανάπτυξη έχει κλείσει.", + "expenses_receipt_invalid": "Η απόδειξη δεν μπόρεσε να διαβαστεί.", + "deployments_attachment_too_large": "Η απόδειξη είναι πολύ μεγάλη." }, "financeMode": { "0": "Επιχειρησιακό", @@ -1152,7 +1172,15 @@ "end_before_start": "Η ώρα λήξης πρέπει να είναι μετά την ώρα έναρξης.", "missing_subject": "Επιλέξτε σε ποιον ή σε τι αφορά αυτή η καταχώριση.", "missing_time": "Εισαγάγετε ώρα έναρξης και λήξης.", - "overlap": "Αυτή η καταχώριση επικαλύπτει άλλη για το ίδιο άτομο ή μονάδα." + "overlap": "Αυτή η καταχώριση επικαλύπτει άλλη για το ίδιο άτομο ή μονάδα.", + "subject_outside_report_scope": "Αυτή η καταχώριση αφορά κάποιον εκτός του πληρώματος ή του προσώπου αυτής της αναφοράς.", + "subject_on_other_report": "Αυτό το πρόσωπο ή η μονάδα έχει ήδη χρόνο σε άλλη αναφορά την ίδια ημέρα.", + "subject_not_on_roster": "Αυτή η καταχώριση αφορά κάποιον που δεν είναι στη σύνθεση.", + "entries_overlap": "Δύο καταχωρίσεις του ίδιου προσώπου ή μονάδας επικαλύπτονται.", + "no_entries": "Προσθέστε τουλάχιστον μία καταχώριση.", + "break_rule": "Μια μεγάλη βάρδια δεν έχει καταγεγραμμένο διάλειμμα.", + "long_travel": "Περισσότερος χρόνος μετακίνησης από όσο επιτρέπεται σε μία ημέρα· χρειάζεται έγκριση.", + "outside_report_date": "Αυτή η καταχώριση είναι εκτός της ημερομηνίας της αναφοράς." }, "mars": { "counts": "{{errors}} σφάλματα · {{warnings}} προειδοποιήσεις", @@ -1201,7 +1229,7 @@ "noReport": "Δεν υπάρχει αναφορά χρόνου για αυτήν την ημέρα.", "notes": "Σημειώσεις", "remove": "Αφαίρεση", - "report": "Αναφορά αρ. {{number}}", + "report": "Αναφορά #{{number}}", "save": "Αποθήκευση καταχωρίσεων", "start": "Έναρξη αναφοράς χρόνου", "subject": "Ποιος ή τι", @@ -1210,7 +1238,19 @@ "to": "Έως", "total": "Σύνολο {{hours}} ώ", "type": "Τύπος καταχώρισης", - "unpaidBreak": "Μη αμειβόμενο διάλειμμα (λεπτά)" + "unpaidBreak": "Μη αμειβόμενο διάλειμμα (λεπτά)", + "crewReport": "Αναφορά χρόνου πληρώματος #{{number}} · {{name}}", + "individualReport": "Αναφορά χρόνου #{{number}} · {{name}}", + "noCrewReport": "Δεν υπάρχει αναφορά χρόνου πληρώματος για {{name}} αυτή την ημέρα.", + "coveredBy": "Αυτός ο χρόνος υπάρχει ήδη στην αναφορά #{{number}} για αυτή την ημέρα.", + "startCrew": "Έναρξη αναφοράς χρόνου πληρώματος", + "readOnly": "Μπορείτε να δείτε αυτή την αναφορά αλλά όχι να την αλλάξετε.", + "nextDay": "Λήγει την επόμενη ημέρα", + "applyToCrew": "Εφαρμογή στο πλήρωμα", + "approve": "Έγκριση αναφοράς", + "dayReports": "Αναφορές αυτής της ημέρας", + "awaitingApproval": "Αναμένει έγκριση ({{count}})", + "notOnRoster": "Δεν είστε στη σύνθεση ή στο πλήρωμα αυτής της ανάπτυξης." }, "title": "Αποστολές", "usage": { @@ -1224,6 +1264,54 @@ "startOdometer": "Χιλιομετρητής έναρξης", "title": "Χρήση", "unit": "Μονάδα" + }, + "crewOf": "Πλήρωμα του {{names}}", + "scope": { + "deployment": "Ολόκληρη η ανάπτυξη", + "mine": "Ο χρόνος μου", + "crew": "Πλήρωμα: {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "Υπογραφές", + "crewBossSigned": "Υπογράφηκε από τον επικεφαλής πληρώματος {{when}}", + "crewBossPending": "Δεν έχει υπογραφεί ακόμη από τον επικεφαλής πληρώματος.", + "customerSigned": "Υπογράφων φορέα: {{name}}", + "customerPending": "Δεν έχει καταγραφεί υπογραφή φορέα.", + "asCrewBoss": "Υπογραφή ως επικεφαλής πληρώματος", + "customerName": "Όνομα υπογράφοντος φορέα", + "recordCustomer": "Καταγραφή υπογραφής φορέα" + }, + "expenses": { + "title": "Έξοδα", + "type": { + "0": "Ημερήσια αποζημίωση γευμάτων", + "1": "Διαμονή", + "2": "Ιδιωτική διαμονή", + "3": "Πορθμείο", + "4": "Καύσιμα", + "5": "Αναπλήρωση υλικών", + "6": "Άλλο" + }, + "typeLabel": "Τύπος εξόδου", + "amount": "Ποσό ({{currency}})", + "description": "Περιγραφή", + "city": "Πόλη", + "takePhoto": "Λήψη φωτογραφίας", + "choosePhoto": "Επιλογή φωτογραφίας", + "receiptAttached": "Επισυνάφθηκε φωτογραφία απόδειξης.", + "save": "Προσθήκη εξόδου", + "add": "Νέο έξοδο", + "noneToday": "Δεν υπάρχουν έξοδα για αυτή την ημέρα.", + "otherDays": "Άλλες ημέρες", + "hasReceipt": "απόδειξη", + "remove": "Αφαίρεση", + "removeTitle": "Αφαίρεση εξόδου;", + "removeBody": "Αυτό αφαιρεί το έξοδο και την απόδειξή του από την ανάπτυξη.", + "cancel": "Άκυρο", + "saved": "Το έξοδο αποθηκεύτηκε.", + "amountInvalid": "Εισαγάγετε ένα ποσό όπως 42.50.", + "photoDenied": "Επιτρέψτε την πρόσβαση στην κάμερα ή στις φωτογραφίες για να επισυνάψετε απόδειξη." } }, "personnel": { @@ -1940,5 +2028,12 @@ "entrance": "Είσοδος", "alert_notes": "Σημειώσεις ειδοποίησης", "files": "Αρχεία χώρου" + }, + "notifications": { + "detail_title": "Ειδοποίηση", + "additional_info": "Πρόσθετες Πληροφορίες", + "view_call": "Προβολή κλήσης", + "view_chat": "Προβολή συνομιλίας", + "open_reference": "Άνοιγμα" } } diff --git a/src/translations/en.json b/src/translations/en.json index dddabefe..23f55aa9 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -163,6 +163,12 @@ "no_location_for_routing": "No location data available for routing", "no_protocols": "No protocols attached to this call", "no_timeline": "No timeline events available", + "activity_link": { + "auto_linked": "auto-linked", + "auto_linked_hint": "Sent without a call; Resgrid linked it from the previous status or the dispatch.", + "inferred": "inferred", + "inferred_hint": "Set with no destination by a unit or person dispatched to this call while working it." + }, "not_available": "N/A", "not_found": "Call not found", "missing_call_id": "Call ID is missing", @@ -678,7 +684,13 @@ "empty": "No files have been attached to this contact.", "share_unavailable": "Sharing is not available on this device.", "download_failed": "The file could not be downloaded." - } + }, + "withheld": "{{count}} protected details are hidden.", + "categoryLabel": "Category: {{name}}", + "physicalAddress": "Physical address", + "mailingAddress": "Mailing address", + "hasGeofence": "This location has a mapped geofence.", + "customFields": "Custom fields" }, "dispatch": { "active_calls": "Active Calls", @@ -1141,7 +1153,15 @@ "disabled": "This feature is turned off for your department.", "locked": "Unlock protected data to continue.", "retry": "Could not reach the server. Try again.", - "validation": "The server rejected the change." + "validation": "The server rejected the change.", + "timereports_subject_covered": "That crew or person already has time on another report for this day.", + "timereports_date_exists": "A report for this crew or person already exists for that day.", + "timereports_scope_invalid": "That crew or person is not active on this deployment.", + "timereports_locked": "This report can no longer be changed.", + "timereports_time_invalid": "Enter times as HH:mm.", + "deployments_closed": "This deployment is closed.", + "expenses_receipt_invalid": "The receipt could not be read.", + "deployments_attachment_too_large": "The receipt is too large." }, "financeMode": { "0": "Operational", @@ -1152,7 +1172,15 @@ "end_before_start": "End time must be after the start time.", "missing_subject": "Pick who or what this entry is for.", "missing_time": "Enter a start and end time.", - "overlap": "This entry overlaps another for the same person or unit." + "overlap": "This entry overlaps another for the same person or unit.", + "subject_outside_report_scope": "This entry is for someone outside this report's crew or person.", + "subject_on_other_report": "This person or unit already has time on another report for the same day.", + "subject_not_on_roster": "This entry is for someone who is not on the roster.", + "entries_overlap": "Two entries overlap for the same person or unit.", + "no_entries": "Add at least one entry.", + "break_rule": "A long shift has no break recorded.", + "long_travel": "More travel than allowed in one day; it needs approval.", + "outside_report_date": "This entry falls outside the report date." }, "mars": { "counts": "{{errors}} errors · {{warnings}} warnings", @@ -1210,7 +1238,19 @@ "to": "To", "total": "Total {{hours}} h", "type": "Entry type", - "unpaidBreak": "Unpaid break (min)" + "unpaidBreak": "Unpaid break (min)", + "crewReport": "Crew time report #{{number}} · {{name}}", + "individualReport": "Time report #{{number}} · {{name}}", + "noCrewReport": "No crew time report for {{name}} on this day.", + "coveredBy": "This time is already on report #{{number}} for this day.", + "startCrew": "Start crew time report", + "readOnly": "You can view this report but not change it.", + "nextDay": "Ends the next day", + "applyToCrew": "Apply to crew", + "approve": "Approve report", + "dayReports": "Reports this day", + "awaitingApproval": "Awaiting approval ({{count}})", + "notOnRoster": "You are not on this deployment's roster or crew." }, "title": "Deployments", "usage": { @@ -1224,6 +1264,54 @@ "startOdometer": "Start odometer", "title": "Usage", "unit": "Unit" + }, + "crewOf": "Crew of {{names}}", + "scope": { + "deployment": "Whole deployment", + "mine": "My time", + "crew": "Crew: {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "Signatures", + "crewBossSigned": "Crew boss signed {{when}}", + "crewBossPending": "Not signed by the crew boss yet.", + "customerSigned": "Agency signer: {{name}}", + "customerPending": "No agency signature recorded.", + "asCrewBoss": "Sign as crew boss", + "customerName": "Agency signer name", + "recordCustomer": "Record agency signature" + }, + "expenses": { + "title": "Expenses", + "type": { + "0": "Meal per diem", + "1": "Lodging", + "2": "Private lodging", + "3": "Ferry", + "4": "Fuel", + "5": "Supply restock", + "6": "Other" + }, + "typeLabel": "Expense type", + "amount": "Amount ({{currency}})", + "description": "Description", + "city": "City", + "takePhoto": "Take photo", + "choosePhoto": "Choose photo", + "receiptAttached": "Receipt photo attached.", + "save": "Add expense", + "add": "New expense", + "noneToday": "No expenses for this day.", + "otherDays": "Other days", + "hasReceipt": "receipt", + "remove": "Remove", + "removeTitle": "Remove expense?", + "removeBody": "This removes the expense and its receipt from the deployment.", + "cancel": "Cancel", + "saved": "Expense saved.", + "amountInvalid": "Enter an amount such as 42.50.", + "photoDenied": "Allow camera or photo access to attach a receipt." } }, "personnel": { @@ -1940,5 +2028,12 @@ "entrance": "Entrance", "alert_notes": "Alert notes", "files": "Site files" + }, + "notifications": { + "detail_title": "Notification", + "additional_info": "Additional Information", + "view_call": "View Call", + "view_chat": "View Chat", + "open_reference": "Open" } } diff --git a/src/translations/es.json b/src/translations/es.json index b91a390e..5d064d63 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -163,6 +163,12 @@ "no_location_for_routing": "No hay datos de ubicación disponibles para el enrutamiento", "no_protocols": "No hay protocolos adjuntos a esta llamada", "no_timeline": "No hay eventos de línea de tiempo disponibles", + "activity_link": { + "auto_linked": "vinculado automáticamente", + "auto_linked_hint": "Enviado sin llamada; Resgrid lo vinculó a partir del estado anterior o del despacho.", + "inferred": "inferido", + "inferred_hint": "Establecido sin destino por una unidad o persona despachada a esta llamada mientras la atendía." + }, "not_available": "N/D", "not_found": "Llamada no encontrada", "missing_call_id": "Falta el ID de la llamada", @@ -678,7 +684,13 @@ "empty": "No se han adjuntado archivos a este contacto.", "share_unavailable": "Compartir no está disponible en este dispositivo.", "download_failed": "No se pudo descargar el archivo." - } + }, + "withheld": "Hay {{count}} datos protegidos ocultos.", + "categoryLabel": "Categoría: {{name}}", + "physicalAddress": "Dirección física", + "mailingAddress": "Dirección postal", + "hasGeofence": "Esta ubicación tiene una geocerca en el mapa.", + "customFields": "Campos personalizados" }, "dispatch": { "active_calls": "Llamadas Activas", @@ -1141,7 +1153,15 @@ "disabled": "Esta función está desactivada para su departamento.", "locked": "Desbloquee los datos protegidos para continuar.", "retry": "No se pudo conectar con el servidor. Inténtelo de nuevo.", - "validation": "El servidor rechazó el cambio." + "validation": "El servidor rechazó el cambio.", + "timereports_subject_covered": "Esa tripulación o persona ya tiene horas en otro parte de este día.", + "timereports_date_exists": "Ya existe un parte para esa tripulación o persona ese día.", + "timereports_scope_invalid": "Esa tripulación o persona no está activa en este despliegue.", + "timereports_locked": "Este parte ya no se puede modificar.", + "timereports_time_invalid": "Introduzca las horas como HH:mm.", + "deployments_closed": "Este despliegue está cerrado.", + "expenses_receipt_invalid": "No se pudo leer el recibo.", + "deployments_attachment_too_large": "El recibo es demasiado grande." }, "financeMode": { "0": "Operativo", @@ -1152,7 +1172,15 @@ "end_before_start": "La hora de fin debe ser posterior a la de inicio.", "missing_subject": "Elija a quién o a qué corresponde esta entrada.", "missing_time": "Introduzca una hora de inicio y de fin.", - "overlap": "Esta entrada se solapa con otra de la misma persona o unidad." + "overlap": "Esta entrada se solapa con otra de la misma persona o unidad.", + "subject_outside_report_scope": "Esta entrada es de alguien ajeno a la tripulación o persona de este parte.", + "subject_on_other_report": "Esta persona o unidad ya tiene horas en otro parte del mismo día.", + "subject_not_on_roster": "Esta entrada es de alguien que no figura en la dotación.", + "entries_overlap": "Dos entradas de la misma persona o unidad se solapan.", + "no_entries": "Añada al menos una entrada.", + "break_rule": "Un turno largo no tiene descanso registrado.", + "long_travel": "Más viaje del permitido en un día; necesita aprobación.", + "outside_report_date": "Esta entrada queda fuera de la fecha del parte." }, "mars": { "counts": "{{errors}} errores · {{warnings}} avisos", @@ -1201,7 +1229,7 @@ "noReport": "No hay parte de tiempo para este día.", "notes": "Notas", "remove": "Quitar", - "report": "Parte n.º {{number}}", + "report": "Parte #{{number}}", "save": "Guardar entradas", "start": "Iniciar parte de tiempo", "subject": "Quién o qué", @@ -1210,7 +1238,19 @@ "to": "A", "total": "Total {{hours}} h", "type": "Tipo de entrada", - "unpaidBreak": "Descanso no pagado (min)" + "unpaidBreak": "Descanso no pagado (min)", + "crewReport": "Parte de horas de tripulación #{{number}} · {{name}}", + "individualReport": "Parte de horas #{{number}} · {{name}}", + "noCrewReport": "No hay parte de tripulación para {{name}} este día.", + "coveredBy": "Estas horas ya están en el parte #{{number}} de este día.", + "startCrew": "Iniciar parte de tripulación", + "readOnly": "Puede ver este parte pero no modificarlo.", + "nextDay": "Termina al día siguiente", + "applyToCrew": "Aplicar a la tripulación", + "approve": "Aprobar parte", + "dayReports": "Partes de este día", + "awaitingApproval": "Pendientes de aprobación ({{count}})", + "notOnRoster": "No figura en la dotación ni en una tripulación de este despliegue." }, "title": "Despliegues", "usage": { @@ -1224,6 +1264,54 @@ "startOdometer": "Odómetro inicial", "title": "Uso", "unit": "Unidad" + }, + "crewOf": "Tripulación de {{names}}", + "scope": { + "deployment": "Todo el despliegue", + "mine": "Mis horas", + "crew": "Tripulación: {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "Firmas", + "crewBossSigned": "Firmado por el jefe de tripulación {{when}}", + "crewBossPending": "Aún sin firma del jefe de tripulación.", + "customerSigned": "Firmante del organismo: {{name}}", + "customerPending": "No hay firma del organismo registrada.", + "asCrewBoss": "Firmar como jefe de tripulación", + "customerName": "Nombre del firmante del organismo", + "recordCustomer": "Registrar firma del organismo" + }, + "expenses": { + "title": "Gastos", + "type": { + "0": "Dieta de comidas", + "1": "Alojamiento", + "2": "Alojamiento privado", + "3": "Ferry", + "4": "Combustible", + "5": "Reposición de material", + "6": "Otro" + }, + "typeLabel": "Tipo de gasto", + "amount": "Importe ({{currency}})", + "description": "Descripción", + "city": "Ciudad", + "takePhoto": "Hacer foto", + "choosePhoto": "Elegir foto", + "receiptAttached": "Foto del recibo adjunta.", + "save": "Añadir gasto", + "add": "Nuevo gasto", + "noneToday": "No hay gastos este día.", + "otherDays": "Otros días", + "hasReceipt": "recibo", + "remove": "Quitar", + "removeTitle": "¿Quitar el gasto?", + "removeBody": "Esto quita el gasto y su recibo del despliegue.", + "cancel": "Cancelar", + "saved": "Gasto guardado.", + "amountInvalid": "Introduzca un importe como 42.50.", + "photoDenied": "Permita el acceso a la cámara o a las fotos para adjuntar un recibo." } }, "personnel": { @@ -1940,5 +2028,12 @@ "entrance": "Entrada", "alert_notes": "Notas de alerta", "files": "Archivos del sitio" + }, + "notifications": { + "detail_title": "Notificación", + "additional_info": "Información adicional", + "view_call": "Ver llamada", + "view_chat": "Ver chat", + "open_reference": "Abrir" } } diff --git a/src/translations/fr.json b/src/translations/fr.json index a6204fae..d3f83208 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -164,6 +164,12 @@ "no_location_for_routing": "Aucune donnée de localisation disponible pour le routage", "no_protocols": "Aucun protocole associé à cet appel", "no_timeline": "Aucun événement chronologique disponible", + "activity_link": { + "auto_linked": "lié automatiquement", + "auto_linked_hint": "Envoyé sans appel ; Resgrid l'a lié à partir du statut précédent ou de la répartition.", + "inferred": "déduit", + "inferred_hint": "Défini sans destination par une unité ou une personne dépêchée sur cet appel pendant son intervention." + }, "not_available": "N/D", "not_found": "Appel introuvable", "missing_call_id": "L'identifiant de l'appel est manquant", @@ -678,7 +684,13 @@ "empty": "Aucun fichier n'est joint à ce contact.", "share_unavailable": "Le partage n'est pas disponible sur cet appareil.", "download_failed": "Le fichier n'a pas pu être téléchargé." - } + }, + "withheld": "{{count}} informations protégées sont masquées.", + "categoryLabel": "Catégorie : {{name}}", + "physicalAddress": "Adresse physique", + "mailingAddress": "Adresse postale", + "hasGeofence": "Ce lieu a un géorepérage cartographié.", + "customFields": "Champs personnalisés" }, "dispatch": { "active_calls": "Appels actifs", @@ -1141,7 +1153,15 @@ "disabled": "Cette fonction est désactivée pour votre service.", "locked": "Déverrouillez les données protégées pour continuer.", "retry": "Serveur injoignable. Réessayez.", - "validation": "Le serveur a refusé la modification." + "validation": "Le serveur a refusé la modification.", + "timereports_subject_covered": "Cet équipage ou cette personne a déjà du temps sur un autre rapport ce jour-là.", + "timereports_date_exists": "Un rapport existe déjà pour cet équipage ou cette personne ce jour-là.", + "timereports_scope_invalid": "Cet équipage ou cette personne n'est pas actif sur ce déploiement.", + "timereports_locked": "Ce rapport ne peut plus être modifié.", + "timereports_time_invalid": "Saisissez les heures au format HH:mm.", + "deployments_closed": "Ce déploiement est clôturé.", + "expenses_receipt_invalid": "Le reçu n'a pas pu être lu.", + "deployments_attachment_too_large": "Le reçu est trop volumineux." }, "financeMode": { "0": "Opérationnel", @@ -1152,7 +1172,15 @@ "end_before_start": "L'heure de fin doit être après l'heure de début.", "missing_subject": "Choisissez à qui ou à quoi correspond cette entrée.", "missing_time": "Saisissez une heure de début et de fin.", - "overlap": "Cette entrée chevauche une autre pour la même personne ou unité." + "overlap": "Cette entrée chevauche une autre pour la même personne ou unité.", + "subject_outside_report_scope": "Cette entrée concerne quelqu'un hors de l'équipage ou de la personne de ce rapport.", + "subject_on_other_report": "Cette personne ou unité a déjà du temps sur un autre rapport le même jour.", + "subject_not_on_roster": "Cette entrée concerne quelqu'un qui n'est pas dans l'effectif.", + "entries_overlap": "Deux entrées de la même personne ou unité se chevauchent.", + "no_entries": "Ajoutez au moins une entrée.", + "break_rule": "Une longue vacation n'a aucune pause enregistrée.", + "long_travel": "Plus de déplacement qu'autorisé en une journée ; une approbation est nécessaire.", + "outside_report_date": "Cette entrée tombe en dehors de la date du rapport." }, "mars": { "counts": "{{errors}} erreurs · {{warnings}} avertissements", @@ -1201,7 +1229,7 @@ "noReport": "Aucun rapport de temps pour ce jour.", "notes": "Notes", "remove": "Supprimer", - "report": "Rapport n° {{number}}", + "report": "Rapport #{{number}}", "save": "Enregistrer les entrées", "start": "Commencer le rapport de temps", "subject": "Qui ou quoi", @@ -1210,7 +1238,19 @@ "to": "À", "total": "Total {{hours}} h", "type": "Type d'entrée", - "unpaidBreak": "Pause non payée (min)" + "unpaidBreak": "Pause non payée (min)", + "crewReport": "Rapport de temps d'équipage #{{number}} · {{name}}", + "individualReport": "Rapport de temps #{{number}} · {{name}}", + "noCrewReport": "Aucun rapport d'équipage pour {{name}} ce jour-là.", + "coveredBy": "Ce temps figure déjà sur le rapport #{{number}} de ce jour.", + "startCrew": "Commencer le rapport d'équipage", + "readOnly": "Vous pouvez consulter ce rapport mais pas le modifier.", + "nextDay": "Se termine le lendemain", + "applyToCrew": "Appliquer à l'équipage", + "approve": "Approuver le rapport", + "dayReports": "Rapports de ce jour", + "awaitingApproval": "En attente d'approbation ({{count}})", + "notOnRoster": "Vous ne faites pas partie de l'effectif ni d'un équipage de ce déploiement." }, "title": "Déploiements", "usage": { @@ -1224,6 +1264,54 @@ "startOdometer": "Compteur début", "title": "Utilisation", "unit": "Unité" + }, + "crewOf": "Équipage de {{names}}", + "scope": { + "deployment": "Tout le déploiement", + "mine": "Mon temps", + "crew": "Équipage : {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "Signatures", + "crewBossSigned": "Signé par le chef d'équipage {{when}}", + "crewBossPending": "Pas encore signé par le chef d'équipage.", + "customerSigned": "Signataire de l'organisme : {{name}}", + "customerPending": "Aucune signature de l'organisme enregistrée.", + "asCrewBoss": "Signer en tant que chef d'équipage", + "customerName": "Nom du signataire de l'organisme", + "recordCustomer": "Enregistrer la signature de l'organisme" + }, + "expenses": { + "title": "Dépenses", + "type": { + "0": "Indemnité de repas", + "1": "Hébergement", + "2": "Hébergement privé", + "3": "Traversier", + "4": "Carburant", + "5": "Réapprovisionnement", + "6": "Autre" + }, + "typeLabel": "Type de dépense", + "amount": "Montant ({{currency}})", + "description": "Description", + "city": "Ville", + "takePhoto": "Prendre une photo", + "choosePhoto": "Choisir une photo", + "receiptAttached": "Photo du reçu jointe.", + "save": "Ajouter la dépense", + "add": "Nouvelle dépense", + "noneToday": "Aucune dépense ce jour-là.", + "otherDays": "Autres jours", + "hasReceipt": "reçu", + "remove": "Supprimer", + "removeTitle": "Supprimer la dépense ?", + "removeBody": "Cela supprime la dépense et son reçu du déploiement.", + "cancel": "Annuler", + "saved": "Dépense enregistrée.", + "amountInvalid": "Saisissez un montant comme 42.50.", + "photoDenied": "Autorisez l'accès à l'appareil photo ou aux photos pour joindre un reçu." } }, "personnel": { @@ -1940,5 +2028,12 @@ "entrance": "Entrée", "alert_notes": "Notes d'alerte", "files": "Fichiers du site" + }, + "notifications": { + "detail_title": "Notification", + "additional_info": "Informations supplémentaires", + "view_call": "Voir l'appel", + "view_chat": "Voir la discussion", + "open_reference": "Ouvrir" } } diff --git a/src/translations/it.json b/src/translations/it.json index bbccda0e..f2255416 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -164,6 +164,12 @@ "no_location_for_routing": "Nessun dato di posizione disponibile per il percorso", "no_protocols": "Nessun protocollo associato a questo intervento", "no_timeline": "Nessun evento nella cronologia disponibile", + "activity_link": { + "auto_linked": "collegato automaticamente", + "auto_linked_hint": "Inviato senza intervento; Resgrid lo ha collegato dallo stato precedente o dall'invio.", + "inferred": "dedotto", + "inferred_hint": "Impostato senza destinazione da un'unità o persona inviata a questo intervento mentre vi operava." + }, "not_available": "N/D", "not_found": "Intervento non trovato", "missing_call_id": "ID intervento mancante", @@ -678,7 +684,13 @@ "empty": "Nessun file allegato a questo contatto.", "share_unavailable": "La condivisione non è disponibile su questo dispositivo.", "download_failed": "Impossibile scaricare il file." - } + }, + "withheld": "{{count}} dettagli protetti sono nascosti.", + "categoryLabel": "Categoria: {{name}}", + "physicalAddress": "Indirizzo fisico", + "mailingAddress": "Indirizzo postale", + "hasGeofence": "Questo luogo ha un geofence mappato.", + "customFields": "Campi personalizzati" }, "dispatch": { "active_calls": "Interventi attivi", @@ -1141,7 +1153,15 @@ "disabled": "Questa funzione è disattivata per il tuo dipartimento.", "locked": "Sblocca i dati protetti per continuare.", "retry": "Impossibile raggiungere il server. Riprova.", - "validation": "Il server ha rifiutato la modifica." + "validation": "Il server ha rifiutato la modifica.", + "timereports_subject_covered": "Questo equipaggio o questa persona ha già ore su un altro rapporto di questo giorno.", + "timereports_date_exists": "Esiste già un rapporto per questo equipaggio o persona in quel giorno.", + "timereports_scope_invalid": "Questo equipaggio o questa persona non è attivo in questo dispiegamento.", + "timereports_locked": "Questo rapporto non può più essere modificato.", + "timereports_time_invalid": "Inserisci gli orari come HH:mm.", + "deployments_closed": "Questo dispiegamento è chiuso.", + "expenses_receipt_invalid": "Impossibile leggere la ricevuta.", + "deployments_attachment_too_large": "La ricevuta è troppo grande." }, "financeMode": { "0": "Operativo", @@ -1152,7 +1172,15 @@ "end_before_start": "L'ora di fine deve essere successiva a quella di inizio.", "missing_subject": "Scegli a chi o a cosa si riferisce questa voce.", "missing_time": "Inserisci un'ora di inizio e di fine.", - "overlap": "Questa voce si sovrappone a un'altra per la stessa persona o unità." + "overlap": "Questa voce si sovrappone a un'altra per la stessa persona o unità.", + "subject_outside_report_scope": "Questa voce riguarda qualcuno al di fuori dell'equipaggio o della persona di questo rapporto.", + "subject_on_other_report": "Questa persona o unità ha già ore su un altro rapporto dello stesso giorno.", + "subject_not_on_roster": "Questa voce riguarda qualcuno che non è nell'organico.", + "entries_overlap": "Due voci della stessa persona o unità si sovrappongono.", + "no_entries": "Aggiungi almeno una voce.", + "break_rule": "Un turno lungo non ha pause registrate.", + "long_travel": "Più viaggio di quanto consentito in un giorno; serve approvazione.", + "outside_report_date": "Questa voce è fuori dalla data del rapporto." }, "mars": { "counts": "{{errors}} errori · {{warnings}} avvisi", @@ -1201,7 +1229,7 @@ "noReport": "Nessun rapporto orario per questo giorno.", "notes": "Note", "remove": "Rimuovi", - "report": "Rapporto n. {{number}}", + "report": "Rapporto #{{number}}", "save": "Salva voci", "start": "Avvia rapporto orario", "subject": "Chi o cosa", @@ -1210,7 +1238,19 @@ "to": "A", "total": "Totale {{hours}} h", "type": "Tipo di voce", - "unpaidBreak": "Pausa non retribuita (min)" + "unpaidBreak": "Pausa non retribuita (min)", + "crewReport": "Rapporto ore dell'equipaggio #{{number}} · {{name}}", + "individualReport": "Rapporto ore #{{number}} · {{name}}", + "noCrewReport": "Nessun rapporto dell'equipaggio per {{name}} in questo giorno.", + "coveredBy": "Queste ore sono già nel rapporto #{{number}} di questo giorno.", + "startCrew": "Avvia rapporto dell'equipaggio", + "readOnly": "Puoi vedere questo rapporto ma non modificarlo.", + "nextDay": "Termina il giorno dopo", + "applyToCrew": "Applica all'equipaggio", + "approve": "Approva rapporto", + "dayReports": "Rapporti di questo giorno", + "awaitingApproval": "In attesa di approvazione ({{count}})", + "notOnRoster": "Non sei nell'organico né in un equipaggio di questo dispiegamento." }, "title": "Dispiegamenti", "usage": { @@ -1224,6 +1264,54 @@ "startOdometer": "Contachilometri iniziale", "title": "Utilizzo", "unit": "Unità" + }, + "crewOf": "Equipaggio di {{names}}", + "scope": { + "deployment": "Intero dispiegamento", + "mine": "Il mio tempo", + "crew": "Equipaggio: {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "Firme", + "crewBossSigned": "Firmato dal capo equipaggio {{when}}", + "crewBossPending": "Non ancora firmato dal capo equipaggio.", + "customerSigned": "Firmatario dell'ente: {{name}}", + "customerPending": "Nessuna firma dell'ente registrata.", + "asCrewBoss": "Firma come capo equipaggio", + "customerName": "Nome del firmatario dell'ente", + "recordCustomer": "Registra la firma dell'ente" + }, + "expenses": { + "title": "Spese", + "type": { + "0": "Diaria pasti", + "1": "Alloggio", + "2": "Alloggio privato", + "3": "Traghetto", + "4": "Carburante", + "5": "Reintegro materiali", + "6": "Altro" + }, + "typeLabel": "Tipo di spesa", + "amount": "Importo ({{currency}})", + "description": "Descrizione", + "city": "Città", + "takePhoto": "Scatta foto", + "choosePhoto": "Scegli foto", + "receiptAttached": "Foto della ricevuta allegata.", + "save": "Aggiungi spesa", + "add": "Nuova spesa", + "noneToday": "Nessuna spesa in questo giorno.", + "otherDays": "Altri giorni", + "hasReceipt": "ricevuta", + "remove": "Rimuovi", + "removeTitle": "Rimuovere la spesa?", + "removeBody": "La spesa e la sua ricevuta vengono rimosse dal dispiegamento.", + "cancel": "Annulla", + "saved": "Spesa salvata.", + "amountInvalid": "Inserisci un importo come 42.50.", + "photoDenied": "Consenti l'accesso alla fotocamera o alle foto per allegare una ricevuta." } }, "personnel": { @@ -1940,5 +2028,12 @@ "entrance": "Ingresso", "alert_notes": "Note di allerta", "files": "File del sito" + }, + "notifications": { + "detail_title": "Notifica", + "additional_info": "Informazioni aggiuntive", + "view_call": "Visualizza intervento", + "view_chat": "Visualizza chat", + "open_reference": "Apri" } } diff --git a/src/translations/pl.json b/src/translations/pl.json index b1ecf78c..3a7734d1 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -164,6 +164,12 @@ "no_location_for_routing": "Brak danych lokalizacyjnych do wyznaczenia trasy", "no_protocols": "Brak protokołów dołączonych do tego zgłoszenia", "no_timeline": "Brak zdarzeń na osi czasu", + "activity_link": { + "auto_linked": "powiązano automatycznie", + "auto_linked_hint": "Wysłano bez zgłoszenia; Resgrid powiązał status na podstawie poprzedniego statusu lub zadysponowania.", + "inferred": "wywnioskowano", + "inferred_hint": "Ustawiony bez celu przez jednostkę lub osobę zadysponowaną do tego zgłoszenia podczas działań przy nim." + }, "not_available": "Nd.", "not_found": "Nie znaleziono zgłoszenia", "missing_call_id": "Brak identyfikatora zgłoszenia", @@ -678,7 +684,13 @@ "empty": "Do tego kontaktu nie dołączono żadnych plików.", "share_unavailable": "Udostępnianie nie jest dostępne na tym urządzeniu.", "download_failed": "Nie udało się pobrać pliku." - } + }, + "withheld": "Ukryto {{count}} chronionych danych.", + "categoryLabel": "Kategoria: {{name}}", + "physicalAddress": "Adres fizyczny", + "mailingAddress": "Adres korespondencyjny", + "hasGeofence": "Ta lokalizacja ma wyznaczoną na mapie strefę geofence.", + "customFields": "Pola niestandardowe" }, "dispatch": { "active_calls": "Aktywne zgłoszenia", @@ -1141,7 +1153,15 @@ "disabled": "Ta funkcja jest wyłączona dla Twojego wydziału.", "locked": "Odblokuj dane chronione, aby kontynuować.", "retry": "Brak połączenia z serwerem. Spróbuj ponownie.", - "validation": "Serwer odrzucił zmianę." + "validation": "Serwer odrzucił zmianę.", + "timereports_subject_covered": "Ta załoga lub osoba ma już czas w innym raporcie z tego dnia.", + "timereports_date_exists": "Raport dla tej załogi lub osoby już istnieje w tym dniu.", + "timereports_scope_invalid": "Ta załoga lub osoba nie jest aktywna w tym rozmieszczeniu.", + "timereports_locked": "Tego raportu nie można już zmienić.", + "timereports_time_invalid": "Wpisz godziny w formacie HH:mm.", + "deployments_closed": "To rozmieszczenie jest zamknięte.", + "expenses_receipt_invalid": "Nie udało się odczytać paragonu.", + "deployments_attachment_too_large": "Paragon jest za duży." }, "financeMode": { "0": "Operacyjne", @@ -1152,7 +1172,15 @@ "end_before_start": "Godzina zakończenia musi być późniejsza niż rozpoczęcia.", "missing_subject": "Wybierz, kogo lub czego dotyczy ten wpis.", "missing_time": "Podaj godzinę rozpoczęcia i zakończenia.", - "overlap": "Ten wpis nakłada się na inny dla tej samej osoby lub jednostki." + "overlap": "Ten wpis nakłada się na inny dla tej samej osoby lub jednostki.", + "subject_outside_report_scope": "Ten wpis dotyczy kogoś spoza załogi lub osoby tego raportu.", + "subject_on_other_report": "Ta osoba lub jednostka ma już czas w innym raporcie z tego samego dnia.", + "subject_not_on_roster": "Ten wpis dotyczy kogoś, kogo nie ma w składzie.", + "entries_overlap": "Dwa wpisy tej samej osoby lub jednostki nakładają się.", + "no_entries": "Dodaj co najmniej jeden wpis.", + "break_rule": "Długa zmiana nie ma zapisanej przerwy.", + "long_travel": "Więcej podróży niż dozwolone w ciągu dnia; wymaga zatwierdzenia.", + "outside_report_date": "Ten wpis wykracza poza datę raportu." }, "mars": { "counts": "{{errors}} błędów · {{warnings}} ostrzeżeń", @@ -1201,7 +1229,7 @@ "noReport": "Brak raportu czasu na ten dzień.", "notes": "Notatki", "remove": "Usuń", - "report": "Raport nr {{number}}", + "report": "Raport #{{number}}", "save": "Zapisz wpisy", "start": "Rozpocznij raport czasu", "subject": "Kto lub co", @@ -1210,7 +1238,19 @@ "to": "Do", "total": "Razem {{hours}} h", "type": "Rodzaj wpisu", - "unpaidBreak": "Przerwa bezpłatna (min)" + "unpaidBreak": "Przerwa bezpłatna (min)", + "crewReport": "Raport czasu załogi #{{number}} · {{name}}", + "individualReport": "Raport czasu #{{number}} · {{name}}", + "noCrewReport": "Brak raportu czasu załogi {{name}} w tym dniu.", + "coveredBy": "Ten czas jest już w raporcie #{{number}} z tego dnia.", + "startCrew": "Rozpocznij raport czasu załogi", + "readOnly": "Możesz przeglądać ten raport, ale nie możesz go zmienić.", + "nextDay": "Kończy się następnego dnia", + "applyToCrew": "Zastosuj do załogi", + "approve": "Zatwierdź raport", + "dayReports": "Raporty z tego dnia", + "awaitingApproval": "Oczekuje na zatwierdzenie ({{count}})", + "notOnRoster": "Nie jesteś w składzie ani w załodze tego rozmieszczenia." }, "title": "Rozmieszczenia", "usage": { @@ -1224,6 +1264,54 @@ "startOdometer": "Licznik początkowy", "title": "Użycie", "unit": "Jednostka" + }, + "crewOf": "Załoga {{names}}", + "scope": { + "deployment": "Całe rozmieszczenie", + "mine": "Mój czas", + "crew": "Załoga: {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "Podpisy", + "crewBossSigned": "Podpisane przez dowódcę załogi {{when}}", + "crewBossPending": "Jeszcze nie podpisane przez dowódcę załogi.", + "customerSigned": "Podpisujący z ramienia instytucji: {{name}}", + "customerPending": "Brak podpisu instytucji.", + "asCrewBoss": "Podpisz jako dowódca załogi", + "customerName": "Imię i nazwisko podpisującego z instytucji", + "recordCustomer": "Zapisz podpis instytucji" + }, + "expenses": { + "title": "Wydatki", + "type": { + "0": "Dieta posiłkowa", + "1": "Zakwaterowanie", + "2": "Zakwaterowanie prywatne", + "3": "Prom", + "4": "Paliwo", + "5": "Uzupełnienie zapasów", + "6": "Inne" + }, + "typeLabel": "Rodzaj wydatku", + "amount": "Kwota ({{currency}})", + "description": "Opis", + "city": "Miasto", + "takePhoto": "Zrób zdjęcie", + "choosePhoto": "Wybierz zdjęcie", + "receiptAttached": "Dołączono zdjęcie paragonu.", + "save": "Dodaj wydatek", + "add": "Nowy wydatek", + "noneToday": "Brak wydatków w tym dniu.", + "otherDays": "Inne dni", + "hasReceipt": "paragon", + "remove": "Usuń", + "removeTitle": "Usunąć wydatek?", + "removeBody": "Spowoduje to usunięcie wydatku i jego paragonu z rozmieszczenia.", + "cancel": "Anuluj", + "saved": "Wydatek zapisany.", + "amountInvalid": "Wpisz kwotę, np. 42.50.", + "photoDenied": "Zezwól na dostęp do aparatu lub zdjęć, aby dołączyć paragon." } }, "personnel": { @@ -1940,5 +2028,12 @@ "entrance": "Wejście", "alert_notes": "Notatki alarmowe", "files": "Pliki obiektu" + }, + "notifications": { + "detail_title": "Powiadomienie", + "additional_info": "Dodatkowe informacje", + "view_call": "Zobacz zgłoszenie", + "view_chat": "Zobacz czat", + "open_reference": "Otwórz" } } diff --git a/src/translations/sv.json b/src/translations/sv.json index 8a152c9d..b1c71d7d 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -164,6 +164,12 @@ "no_location_for_routing": "Ingen platsdata tillgänglig för vägbeskrivning", "no_protocols": "Inga protokoll kopplade till detta ärende", "no_timeline": "Inga tidslinjehändelser tillgängliga", + "activity_link": { + "auto_linked": "autolänkad", + "auto_linked_hint": "Skickad utan ärende; Resgrid länkade den från föregående status eller utlarmningen.", + "inferred": "härledd", + "inferred_hint": "Satt utan destination av en enhet eller person som larmats ut till detta ärende under arbetet med det." + }, "not_available": "Ej tillgänglig", "not_found": "Ärende hittades inte", "missing_call_id": "Ärende-ID saknas", @@ -678,7 +684,13 @@ "empty": "Inga filer har bifogats denna kontakt.", "share_unavailable": "Delning är inte tillgänglig på den här enheten.", "download_failed": "Filen kunde inte laddas ner." - } + }, + "withheld": "{{count}} skyddade uppgifter är dolda.", + "categoryLabel": "Kategori: {{name}}", + "physicalAddress": "Besöksadress", + "mailingAddress": "Postadress", + "hasGeofence": "Platsen har ett inritat geostaket.", + "customFields": "Anpassade fält" }, "dispatch": { "active_calls": "Aktiva ärenden", @@ -1141,7 +1153,15 @@ "disabled": "Funktionen är avstängd för din avdelning.", "locked": "Lås upp skyddade data för att fortsätta.", "retry": "Kunde inte nå servern. Försök igen.", - "validation": "Servern avvisade ändringen." + "validation": "Servern avvisade ändringen.", + "timereports_subject_covered": "Den besättningen eller personen har redan tid i en annan rapport denna dag.", + "timereports_date_exists": "Det finns redan en rapport för den besättningen eller personen den dagen.", + "timereports_scope_invalid": "Den besättningen eller personen är inte aktiv i denna insats.", + "timereports_locked": "Rapporten kan inte längre ändras.", + "timereports_time_invalid": "Ange tider som HH:mm.", + "deployments_closed": "Insatsen är avslutad.", + "expenses_receipt_invalid": "Kvittot kunde inte läsas.", + "deployments_attachment_too_large": "Kvittot är för stort." }, "financeMode": { "0": "Operativ", @@ -1152,7 +1172,15 @@ "end_before_start": "Sluttiden måste vara efter starttiden.", "missing_subject": "Välj vem eller vad posten gäller.", "missing_time": "Ange start- och sluttid.", - "overlap": "Posten överlappar en annan för samma person eller enhet." + "overlap": "Posten överlappar en annan för samma person eller enhet.", + "subject_outside_report_scope": "Posten gäller någon utanför rapportens besättning eller person.", + "subject_on_other_report": "Den här personen eller enheten har redan tid i en annan rapport samma dag.", + "subject_not_on_roster": "Posten gäller någon som inte finns i bemanningen.", + "entries_overlap": "Två poster för samma person eller enhet överlappar.", + "no_entries": "Lägg till minst en post.", + "break_rule": "Ett långt pass saknar registrerad rast.", + "long_travel": "Mer resa än tillåtet på en dag; kräver godkännande.", + "outside_report_date": "Posten ligger utanför rapportens datum." }, "mars": { "counts": "{{errors}} fel · {{warnings}} varningar", @@ -1201,7 +1229,7 @@ "noReport": "Ingen tidrapport för den här dagen.", "notes": "Anteckningar", "remove": "Ta bort", - "report": "Rapport nr {{number}}", + "report": "Rapport #{{number}}", "save": "Spara poster", "start": "Starta tidrapport", "subject": "Vem eller vad", @@ -1210,7 +1238,19 @@ "to": "Till", "total": "Totalt {{hours}} h", "type": "Posttyp", - "unpaidBreak": "Obetald rast (min)" + "unpaidBreak": "Obetald rast (min)", + "crewReport": "Besättningens tidrapport #{{number}} · {{name}}", + "individualReport": "Tidrapport #{{number}} · {{name}}", + "noCrewReport": "Ingen tidrapport för besättningen {{name}} denna dag.", + "coveredBy": "Den här tiden finns redan i rapport #{{number}} för denna dag.", + "startCrew": "Starta besättningens tidrapport", + "readOnly": "Du kan se rapporten men inte ändra den.", + "nextDay": "Slutar nästa dag", + "applyToCrew": "Använd för besättningen", + "approve": "Godkänn rapport", + "dayReports": "Rapporter denna dag", + "awaitingApproval": "Väntar på godkännande ({{count}})", + "notOnRoster": "Du finns inte i bemanningen eller någon besättning i denna insats." }, "title": "Insatser", "usage": { @@ -1224,6 +1264,54 @@ "startOdometer": "Mätarställning start", "title": "Användning", "unit": "Enhet" + }, + "crewOf": "Besättning på {{names}}", + "scope": { + "deployment": "Hela insatsen", + "mine": "Min tid", + "crew": "Besättning: {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "Underskrifter", + "crewBossSigned": "Signerad av besättningschefen {{when}}", + "crewBossPending": "Ännu inte signerad av besättningschefen.", + "customerSigned": "Myndighetens undertecknare: {{name}}", + "customerPending": "Ingen underskrift från myndigheten registrerad.", + "asCrewBoss": "Signera som besättningschef", + "customerName": "Myndighetens undertecknares namn", + "recordCustomer": "Registrera myndighetens underskrift" + }, + "expenses": { + "title": "Utlägg", + "type": { + "0": "Traktamente måltider", + "1": "Logi", + "2": "Privat logi", + "3": "Färja", + "4": "Bränsle", + "5": "Materialpåfyllning", + "6": "Övrigt" + }, + "typeLabel": "Typ av utlägg", + "amount": "Belopp ({{currency}})", + "description": "Beskrivning", + "city": "Ort", + "takePhoto": "Ta foto", + "choosePhoto": "Välj foto", + "receiptAttached": "Kvittofoto bifogat.", + "save": "Lägg till utlägg", + "add": "Nytt utlägg", + "noneToday": "Inga utlägg denna dag.", + "otherDays": "Andra dagar", + "hasReceipt": "kvitto", + "remove": "Ta bort", + "removeTitle": "Ta bort utlägget?", + "removeBody": "Utlägget och dess kvitto tas bort från insatsen.", + "cancel": "Avbryt", + "saved": "Utlägget sparades.", + "amountInvalid": "Ange ett belopp som 42.50.", + "photoDenied": "Tillåt åtkomst till kamera eller bilder för att bifoga ett kvitto." } }, "personnel": { @@ -1940,5 +2028,12 @@ "entrance": "Entré", "alert_notes": "Varningsanteckningar", "files": "Objektfiler" + }, + "notifications": { + "detail_title": "Avisering", + "additional_info": "Ytterligare information", + "view_call": "Visa ärende", + "view_chat": "Visa chatt", + "open_reference": "Öppna" } } diff --git a/src/translations/uk.json b/src/translations/uk.json index f9bb1019..f991cb45 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -164,6 +164,12 @@ "no_location_for_routing": "Дані про місцезнаходження для маршрутизації відсутні", "no_protocols": "До цього виклику не прикріплено протоколів", "no_timeline": "Події хронології відсутні", + "activity_link": { + "auto_linked": "пов'язано автоматично", + "auto_linked_hint": "Надіслано без виклику; Resgrid пов'язав статус на основі попереднього статусу або диспетчеризації.", + "inferred": "виведено", + "inferred_hint": "Встановлено без пункту призначення підрозділом або особою, диспетчеризованими на цей виклик, під час роботи над ним." + }, "not_available": "Н/Д", "not_found": "Виклик не знайдено", "missing_call_id": "Відсутній ID виклику", @@ -678,7 +684,13 @@ "empty": "До цього контакту не додано файлів.", "share_unavailable": "Поширення недоступне на цьому пристрої.", "download_failed": "Не вдалося завантажити файл." - } + }, + "withheld": "Приховано {{count}} захищених даних.", + "categoryLabel": "Категорія: {{name}}", + "physicalAddress": "Фактична адреса", + "mailingAddress": "Поштова адреса", + "hasGeofence": "Для цього місця на карті позначено геозону.", + "customFields": "Власні поля" }, "dispatch": { "active_calls": "Активні виклики", @@ -1141,7 +1153,15 @@ "disabled": "Цю функцію вимкнено для вашого підрозділу.", "locked": "Розблокуйте захищені дані, щоб продовжити.", "retry": "Не вдалося з’єднатися з сервером. Спробуйте ще раз.", - "validation": "Сервер відхилив зміну." + "validation": "Сервер відхилив зміну.", + "timereports_subject_covered": "Цей екіпаж або особа вже мають час в іншому звіті за цей день.", + "timereports_date_exists": "Звіт для цього екіпажу або особи за той день уже існує.", + "timereports_scope_invalid": "Цей екіпаж або особа не активні в цьому розгортанні.", + "timereports_locked": "Цей звіт більше не можна змінити.", + "timereports_time_invalid": "Вводьте час у форматі HH:mm.", + "deployments_closed": "Це розгортання закрите.", + "expenses_receipt_invalid": "Не вдалося прочитати чек.", + "deployments_attachment_too_large": "Чек завеликий." }, "financeMode": { "0": "Оперативне", @@ -1152,7 +1172,15 @@ "end_before_start": "Час завершення має бути пізніше за час початку.", "missing_subject": "Виберіть, кого або чого стосується цей запис.", "missing_time": "Введіть час початку та завершення.", - "overlap": "Цей запис перетинається з іншим для тієї самої особи або підрозділу." + "overlap": "Цей запис перетинається з іншим для тієї самої особи або підрозділу.", + "subject_outside_report_scope": "Цей запис стосується когось поза екіпажем або особою цього звіту.", + "subject_on_other_report": "Ця особа або підрозділ уже має час в іншому звіті за той самий день.", + "subject_not_on_roster": "Цей запис стосується когось, кого немає у складі.", + "entries_overlap": "Два записи тієї самої особи або підрозділу перекриваються.", + "no_entries": "Додайте принаймні один запис.", + "break_rule": "Для довгої зміни не записано перерви.", + "long_travel": "Більше переїздів, ніж дозволено за день; потрібне затвердження.", + "outside_report_date": "Цей запис виходить за межі дати звіту." }, "mars": { "counts": "{{errors}} помилок · {{warnings}} попереджень", @@ -1201,7 +1229,7 @@ "noReport": "Немає звіту про час за цей день.", "notes": "Нотатки", "remove": "Видалити", - "report": "Звіт № {{number}}", + "report": "Звіт #{{number}}", "save": "Зберегти записи", "start": "Почати звіт про час", "subject": "Хто або що", @@ -1210,7 +1238,19 @@ "to": "До", "total": "Разом {{hours}} год", "type": "Тип запису", - "unpaidBreak": "Неоплачувана перерва (хв)" + "unpaidBreak": "Неоплачувана перерва (хв)", + "crewReport": "Звіт про час екіпажу #{{number}} · {{name}}", + "individualReport": "Звіт про час #{{number}} · {{name}}", + "noCrewReport": "Немає звіту про час екіпажу {{name}} за цей день.", + "coveredBy": "Цей час уже є у звіті #{{number}} за цей день.", + "startCrew": "Почати звіт про час екіпажу", + "readOnly": "Ви можете переглядати цей звіт, але не змінювати його.", + "nextDay": "Закінчується наступного дня", + "applyToCrew": "Застосувати до екіпажу", + "approve": "Затвердити звіт", + "dayReports": "Звіти за цей день", + "awaitingApproval": "Очікують затвердження ({{count}})", + "notOnRoster": "Ви не входите до складу чи екіпажу цього розгортання." }, "title": "Розгортання", "usage": { @@ -1224,6 +1264,54 @@ "startOdometer": "Одометр на початку", "title": "Використання", "unit": "Підрозділ" + }, + "crewOf": "Екіпаж {{names}}", + "scope": { + "deployment": "Усе розгортання", + "mine": "Мій час", + "crew": "Екіпаж: {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "Підписи", + "crewBossSigned": "Підписано командиром екіпажу {{when}}", + "crewBossPending": "Командир екіпажу ще не підписав.", + "customerSigned": "Підписант установи: {{name}}", + "customerPending": "Підпис установи не записано.", + "asCrewBoss": "Підписати як командир екіпажу", + "customerName": "Ім'я підписанта установи", + "recordCustomer": "Записати підпис установи" + }, + "expenses": { + "title": "Витрати", + "type": { + "0": "Добові на харчування", + "1": "Проживання", + "2": "Приватне проживання", + "3": "Пором", + "4": "Пальне", + "5": "Поповнення запасів", + "6": "Інше" + }, + "typeLabel": "Тип витрати", + "amount": "Сума ({{currency}})", + "description": "Опис", + "city": "Місто", + "takePhoto": "Зробити фото", + "choosePhoto": "Вибрати фото", + "receiptAttached": "Фото чека додано.", + "save": "Додати витрату", + "add": "Нова витрата", + "noneToday": "Немає витрат за цей день.", + "otherDays": "Інші дні", + "hasReceipt": "чек", + "remove": "Видалити", + "removeTitle": "Видалити витрату?", + "removeBody": "Це видалить витрату та її чек із розгортання.", + "cancel": "Скасувати", + "saved": "Витрату збережено.", + "amountInvalid": "Введіть суму, наприклад 42.50.", + "photoDenied": "Дозвольте доступ до камери або фото, щоб додати чек." } }, "personnel": { @@ -1940,5 +2028,12 @@ "entrance": "Вхід", "alert_notes": "Тривожні нотатки", "files": "Файли об'єкта" + }, + "notifications": { + "detail_title": "Сповіщення", + "additional_info": "Додаткова інформація", + "view_call": "Переглянути виклик", + "view_chat": "Переглянути чат", + "open_reference": "Відкрити" } } diff --git a/src/types/notification.ts b/src/types/notification.ts index 0559b9ed..cecec533 100644 --- a/src/types/notification.ts +++ b/src/types/notification.ts @@ -6,7 +6,7 @@ export interface NotificationPayload { read?: boolean; type?: string; referenceId?: string; - referenceType?: 'call' | 'message' | 'status' | 'note' | 'other'; + referenceType?: 'call' | 'message' | 'status' | 'note' | 'chat' | 'other'; metadata?: Record; } From 0574ee11e12431b03b189ab7e421a8c418e0f4cf Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 25 Sep 2026 18:39:36 -0700 Subject: [PATCH 2/5] RD-T44 Expo 57 Update, Server URL fixes, Realtime Map fixes, Map pin fixes --- __mocks__/expo-av.ts | 36 - app.config.ts | 24 +- electron/main.js | 11 + jest-env-setup.js | 4 + jest-setup.ts | 3 +- jest.config.js | 12 +- jest.resolver.js | 15 - package.json | 135 +- ...rc.0.patch => @rnmapbox+maps+10.3.5.patch} | 0 ...tch => react-native-webview+13.16.1.patch} | 0 plugins/__tests__/withIconBadge.test.ts | 113 + ...withResourceBundleDeploymentTarget.test.ts | 28 + plugins/withIconBadge.js | 118 + plugins/withResourceBundleDeploymentTarget.js | 43 + src/__tests__/app/call/[id].security.test.tsx | 11 +- src/__tests__/app/call/[id].test.tsx | 10 +- src/__tests__/app/calls.test.tsx | 13 +- src/__tests__/app/index.test.tsx | 21 +- src/__tests__/app/map.web.test.tsx | 193 + src/__tests__/app/root/lockscreen.test.tsx | 6 +- src/__tests__/app/root/maintenance.test.tsx | 4 +- src/api/config/__tests__/index.test.ts | 38 + src/api/config/index.ts | 14 +- src/app/(app)/_layout.tsx | 19 + src/app/(app)/calls.tsx | 3 +- src/app/(app)/home.tsx | 3 +- src/app/(app)/home.web.tsx | 3 +- src/app/(app)/map.tsx | 35 +- src/app/(app)/map.web.tsx | 166 +- src/app/(app)/personnel.tsx | 3 +- src/app/(app)/pois.tsx | 3 +- src/app/(app)/scheduled-calls.tsx | 3 +- src/app/(app)/settings.tsx | 11 +- src/app/(app)/units.tsx | 3 +- src/app/(app)/weather-alerts/index.tsx | 3 +- src/app/_layout.tsx | 4 +- src/app/login/index.web.tsx | 18 +- src/app/personnel/[id].tsx | 16 +- src/app/units/[id].tsx | 16 +- .../callVideoFeeds/video-player.tsx | 2 +- .../__tests__/call-notes-modal-new.test.tsx | 2 +- .../__tests__/udf-fields-renderer.test.tsx | 175 + src/components/calls/call-audio-modal.tsx | 29 +- src/components/calls/udf-fields-renderer.tsx | 167 +- .../__tests__/contact-details-extra.test.tsx | 68 + .../contacts/contact-details-extra.tsx | 2 +- .../maps/__tests__/pin-actions.test.tsx | 30 +- .../__tests__/unified-map-view.web.test.tsx | 160 + src/components/maps/pin-detail-modal.tsx | 13 +- src/components/maps/unified-map-view.tsx | 88 +- src/components/maps/unified-map-view.web.tsx | 126 +- .../login-info-bottom-sheet-simple.test.tsx | 2 +- .../login-info-bottom-sheet.test.tsx | 2 +- .../server-url-bottom-sheet-simple.test.tsx | 4 +- .../server-url-bottom-sheet.test.tsx | 217 + .../settings/server-url-bottom-sheet.tsx | 48 +- .../__tests__/status-bottom-sheet.test.tsx | 4 +- src/components/ui/alert-dialog/index.tsx | 1 - src/components/ui/focus-aware-status-bar.tsx | 17 +- .../ui/gluestack-ui-provider/index.tsx | 2 +- src/components/ui/menu/index.tsx | 1 - src/components/ui/modal/index.tsx | 1 - .../__tests__/use-map-live-locations.test.tsx | 260 ++ .../__tests__/use-map-signalr-updates.test.ts | 26 +- src/hooks/__tests__/use-ptt.test.ts | 6 - src/hooks/use-map-live-locations.ts | 170 + src/hooks/use-map-signalr-updates.ts | 24 +- src/hooks/use-ptt.ts | 14 +- src/lib/__tests__/live-locations.test.ts | 221 ++ src/lib/__tests__/map-pin-ids.test.ts | 69 + src/lib/__tests__/poi-map-layers.test.ts | 31 + src/lib/__tests__/server-url.test.ts | 93 + .../__tests__/use-selected-theme.test.ts | 2 +- src/lib/hooks/use-selected-theme.tsx | 4 +- src/lib/live-locations.ts | 206 + src/lib/map-pin-ids.ts | 58 + src/lib/poi-map-layers.ts | 24 + src/lib/server-url.ts | 58 + src/lib/storage/__tests__/app.test.ts | 47 + src/lib/storage/app.tsx | 12 +- src/lib/test-utils.tsx | 7 +- src/lib/udf/__tests__/options.test.ts | 97 + src/lib/udf/options.ts | 117 + src/models/v4/contacts/contactResultData.ts | 3 + .../userDefinedFields/udfFieldResultData.ts | 6 +- src/services/__tests__/audio.service.test.ts | 119 +- .../signalr.service.geolocation.test.ts | 197 + .../__tests__/signalr.service.test.ts | 37 + src/services/audio.service.ts | 83 +- src/services/signalr.service.ts | 57 +- .../app/__tests__/audio-stream-store.test.ts | 166 +- .../app/__tests__/livekit-store.test.ts | 11 +- src/stores/app/audio-stream-store.ts | 149 +- .../signalr-store.geolocation.test.ts | 302 ++ .../signalr/__tests__/signalr-store.test.ts | 16 +- src/stores/signalr/signalr-store.ts | 205 +- src/translations/ar.json | 1 + src/translations/de.json | 1 + src/translations/el.json | 1 + src/translations/en.json | 1 + src/translations/es.json | 1 + src/translations/fr.json | 1 + src/translations/it.json | 1 + src/translations/pl.json | 1 + src/translations/sv.json | 1 + src/translations/uk.json | 1 + tsconfig.json | 1 - yarn.lock | 3485 ++++++++--------- 108 files changed, 6166 insertions(+), 2549 deletions(-) delete mode 100644 __mocks__/expo-av.ts create mode 100644 jest-env-setup.js delete mode 100644 jest.resolver.js rename patches/{@rnmapbox+maps+10.1.42-rc.0.patch => @rnmapbox+maps+10.3.5.patch} (100%) rename patches/{react-native-webview+13.15.0.patch => react-native-webview+13.16.1.patch} (100%) create mode 100644 plugins/__tests__/withIconBadge.test.ts create mode 100644 plugins/__tests__/withResourceBundleDeploymentTarget.test.ts create mode 100644 plugins/withIconBadge.js create mode 100644 plugins/withResourceBundleDeploymentTarget.js create mode 100644 src/__tests__/app/map.web.test.tsx create mode 100644 src/api/config/__tests__/index.test.ts create mode 100644 src/components/calls/__tests__/udf-fields-renderer.test.tsx create mode 100644 src/components/contacts/__tests__/contact-details-extra.test.tsx create mode 100644 src/components/maps/__tests__/unified-map-view.web.test.tsx create mode 100644 src/components/settings/__tests__/server-url-bottom-sheet.test.tsx create mode 100644 src/hooks/__tests__/use-map-live-locations.test.tsx create mode 100644 src/hooks/use-map-live-locations.ts create mode 100644 src/lib/__tests__/live-locations.test.ts create mode 100644 src/lib/__tests__/map-pin-ids.test.ts create mode 100644 src/lib/__tests__/poi-map-layers.test.ts create mode 100644 src/lib/__tests__/server-url.test.ts create mode 100644 src/lib/live-locations.ts create mode 100644 src/lib/map-pin-ids.ts create mode 100644 src/lib/storage/__tests__/app.test.ts create mode 100644 src/lib/udf/__tests__/options.test.ts create mode 100644 src/lib/udf/options.ts create mode 100644 src/services/__tests__/signalr.service.geolocation.test.ts create mode 100644 src/stores/signalr/__tests__/signalr-store.geolocation.test.ts diff --git a/__mocks__/expo-av.ts b/__mocks__/expo-av.ts deleted file mode 100644 index 500b5988..00000000 --- a/__mocks__/expo-av.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Mock for expo-av -export const Audio = { - setAudioModeAsync: jest.fn().mockResolvedValue(undefined), - Sound: class MockSound { - static createAsync = jest.fn().mockResolvedValue({ - sound: new this(), - status: { isLoaded: true }, - }); - - playAsync = jest.fn().mockResolvedValue({ status: { isPlaying: true } }); - stopAsync = jest.fn().mockResolvedValue({ status: { isPlaying: false } }); - unloadAsync = jest.fn().mockResolvedValue(undefined); - setVolumeAsync = jest.fn().mockResolvedValue(undefined); - }, - setIsEnabledAsync: jest.fn().mockResolvedValue(undefined), - getPermissionsAsync: jest.fn().mockResolvedValue({ - granted: true, - canAskAgain: true, - expires: 'never', - status: 'granted', - }), - requestPermissionsAsync: jest.fn().mockResolvedValue({ - granted: true, - canAskAgain: true, - expires: 'never', - status: 'granted', - }), -}; - -export const InterruptionModeIOS = { - MixWithOthers: 0, - DoNotMix: 1, - DuckOthers: 2, -}; - -export const AVPlaybackSource = {}; diff --git a/app.config.ts b/app.config.ts index 62503963..f8eeab17 100644 --- a/app.config.ts +++ b/app.config.ts @@ -34,7 +34,6 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ orientation: 'default', icon: './assets/icon.png', userInterfaceStyle: 'automatic', - newArchEnabled: true, updates: { fallbackToCacheTimeout: 0, }, @@ -212,7 +211,10 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ [ '@rnmapbox/maps', { - RNMapboxMapsVersion: '11.8.0', + // Keep in step with the `mapbox` field of the installed @rnmapbox/maps — the JS + // bindings are generated against a specific native SDK, and pinning an older one + // makes style props the bindings emit (symbolZOffset and friends) trap natively. + RNMapboxMapsVersion: '11.23.1', }, ], [ @@ -224,6 +226,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ 'Resgrid Dispatch uses your location, including in the background, to keep the department map updated with your position. For example, while you are working a call away from the console, your location is periodically sent so other dispatchers and responders can see where you are, even when the app is not on screen.', locationAlwaysPermission: 'Resgrid Dispatch uses your location in the background to keep the department map updated with your position. For example, while you are working a call away from the console, your location is periodically sent so other dispatchers and responders can see where you are, even when the app is not on screen.', + // Required even though getMotionActivityAsync() is never called: expo-location links + // CoreMotion (MotionActivityPermissionRequester), and App Store static analysis rejects + // the binary with ITMS-90683 whenever the framework is referenced and the string is absent. + motionUsagePermission: + 'Resgrid Dispatch uses motion data to improve the accuracy of the location shown on the department map. For example, while you are driving to an incident, motion data helps distinguish travel from a stop so other dispatchers and responders see an accurate position.', isIosBackgroundLocationEnabled: true, isAndroidBackgroundLocationEnabled: true, isAndroidForegroundServiceEnabled: true, @@ -257,10 +264,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ android: { extraProguardRules: '-keep class expo.modules.location.** { *; }', extraMavenRepos: ['../../node_modules/@notifee/react-native/android/libs'], - targetSdkVersion: 35, + targetSdkVersion: 36, }, ios: { deploymentTarget: '18.1', + // Apps built with the iOS 27 SDK must adopt the UIKit scene life cycle or they are + // killed at launch. Remove once on SDK 58, whose template adopts it by default. + enableSceneSupport: true, }, }, ], @@ -304,7 +314,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ 'expo-navigation-bar', { position: 'relative', - visibility: 'hidden', + hidden: true, behavior: 'inset-touch', }, ], @@ -328,9 +338,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ], '@config-plugins/react-native-callkeep', 'expo-web-browser', + 'expo-image', + 'expo-sharing', + 'expo-status-bar', './customGradle.plugin.js', './customManifest.plugin.js', - ['app-icon-badge', appIconBadgeConfig], + './plugins/withResourceBundleDeploymentTarget.js', + ['./plugins/withIconBadge.js', appIconBadgeConfig], ], extra: { ...ClientEnv, diff --git a/electron/main.js b/electron/main.js index 7cfec462..9a5d1cb1 100644 --- a/electron/main.js +++ b/electron/main.js @@ -275,6 +275,17 @@ app.whenReady().then(() => { }); }); +// Settings such as the selected server URL live in the renderer's localStorage, which +// Chromium writes to disk lazily. Flush it before exiting so a change made just before +// quitting survives the next launch. +app.on('before-quit', () => { + try { + session.defaultSession.flushStorageData(); + } catch (err) { + console.error('Failed to flush storage data before quit:', err); + } +}); + app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); diff --git a/jest-env-setup.js b/jest-env-setup.js new file mode 100644 index 00000000..2a4c8f5e --- /dev/null +++ b/jest-env-setup.js @@ -0,0 +1,4 @@ +// Must run before jest-expo's preset setup requires `expo/src/winter`. +// Uses React Native's fetch in tests instead of expo's winter fetch, whose +// FetchResponse extends a native class that is unavailable after jest teardown. +process.env.EXPO_PUBLIC_USE_RN_FETCH = '1'; diff --git a/jest-setup.ts b/jest-setup.ts index 6f7a0323..55c56082 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -13,7 +13,8 @@ jest.mock('expo-audio', () => ({ pause: jest.fn(), remove: jest.fn(), replace: jest.fn(), - seekTo: jest.fn(), + seekTo: jest.fn().mockResolvedValue(undefined), + addListener: jest.fn(() => ({ remove: jest.fn() })), playing: false, paused: false, isLoaded: true, diff --git a/jest.config.js b/jest.config.js index 63c1806e..dfe0781b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,8 +1,14 @@ +const expoPreset = require('jest-expo/jest-preset'); + +// Must be set before babel-preset-expo transforms modules (it inlines +// EXPO_PUBLIC_* vars) — makes expo's winter runtime keep RN's fetch in tests. +process.env.EXPO_PUBLIC_USE_RN_FETCH = '1'; + module.exports = { preset: 'jest-expo', // Reanimated v4 worklets: resolve to the non-native builds in Jest - // (vendored — worklets 0.5.1 does not ship jest/resolver.js) - resolver: '/jest.resolver.js', + resolver: 'react-native-worklets/jest/resolver.js', + setupFiles: ['/jest-env-setup.js', ...(expoPreset.setupFiles ?? [])], setupFilesAfterEnv: ['/jest-setup.ts'], testMatch: ['**/?(*.)+(spec|test).ts?(x)'], testPathIgnorePatterns: ['/node_modules/', '\\.\\._.*'], @@ -11,7 +17,7 @@ module.exports = { moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], moduleDirectories: ['node_modules', '/'], transformIgnorePatterns: [ - 'node_modules/(?!((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|@legendapp/motion|@gluestack-ui|nativewind|react-native-css|expo-audio|@aptabase/.*|@shopify/flash-list))', + 'node_modules/(?!((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|standard-navigation|@sentry/react-native|native-base|react-native-svg|@legendapp/motion|@gluestack-ui|nativewind|react-native-css|expo-audio|@aptabase/.*|@dev-plugins/.*|@shopify/flash-list))', ], coverageReporters: ['json-summary', ['text', { file: 'coverage.txt' }], 'cobertura'], reporters: [ diff --git a/jest.resolver.js b/jest.resolver.js deleted file mode 100644 index c889009b..00000000 --- a/jest.resolver.js +++ /dev/null @@ -1,15 +0,0 @@ -// Reanimated v4 worklets: resolve to the non-native builds in Jest. -// Vendored copy of react-native-worklets/jest/resolver.js (added in worklets -// 0.8.x) — this repo pins react-native-worklets 0.5.1, which does not bundle it. - -/** @type {import('jest-resolve').SyncResolver} */ -module.exports = (request, options) => { - const { defaultResolver } = options; - if (options.basedir.includes('react-native-worklets') || request.includes('react-native-worklets')) { - const workletOptions = { ...options }; - workletOptions.extensions = workletOptions.extensions?.filter((ext) => !ext.includes('native')); - options = workletOptions; - } - - return defaultResolver(request, options); -}; diff --git a/package.json b/package.json index a8fd0217..706666b0 100644 --- a/package.json +++ b/package.json @@ -87,30 +87,30 @@ } }, "dependencies": { - "@config-plugins/react-native-callkeep": "^11.0.0", - "@config-plugins/react-native-webrtc": "~12.0.0", - "@dev-plugins/react-query": "~0.2.0", - "@expo/config-plugins": "~54.0.4", + "@config-plugins/react-native-callkeep": "14.0.1", + "@config-plugins/react-native-webrtc": "15.0.1", + "@dev-plugins/react-query": "0.4.0", + "@expo/config-plugins": "~57.0.9", "@expo/html-elements": "~0.10.1", - "@expo/metro-runtime": "~6.1.2", + "@expo/metro-runtime": "~57.0.16", "@gluestack-ui/core": "^5.0.15", "@gluestack-ui/utils": "^5.0.6", "@gorhom/bottom-sheet": "~5.0.5", "@hookform/resolvers": "~3.9.0", "@legendapp/motion": "~2.4.0", - "@livekit/react-native": "^2.9.1", - "@livekit/react-native-expo-plugin": "^1.0.1", - "@livekit/react-native-webrtc": "^137.0.2", + "@livekit/react-native": "2.12.0", + "@livekit/react-native-expo-plugin": "1.0.2", + "@livekit/react-native-webrtc": "144.1.2", "@microsoft/signalr": "~8.0.7", "@notifee/react-native": "^9.1.8", "@novu/react-native": "~2.6.6", "@react-buoy/core": "^1.5.23", "@react-buoy/env": "^1.5.23", "@react-buoy/network": "^1.5.23", - "@react-native-community/netinfo": "^11.4.1", - "@rnmapbox/maps": "10.1.42-rc.0", + "@react-native-community/netinfo": "12.0.1", + "@rnmapbox/maps": "10.3.5", "@semantic-release/git": "^10.0.1", - "@sentry/react-native": "~7.2.0", + "@sentry/react-native": "~8.20.0", "@shopify/flash-list": "2.0.2", "@tanstack/react-query": "~5.52.1", "app-icon-badge": "^0.1.2", @@ -120,73 +120,72 @@ "countly-sdk-react-native-bridge": "^25.4.0", "date-fns": "^4.1.0", "dompurify": "^3.3.1", - "expo": "~54.0.37", - "expo-application": "~7.0.8", - "expo-asset": "~12.0.13", - "expo-audio": "~1.1.1", - "expo-auth-session": "~7.0.11", - "expo-av": "~16.0.8", - "expo-build-properties": "~1.0.10", - "expo-clipboard": "~8.0.8", - "expo-constants": "~18.0.14", - "expo-crypto": "~15.0.9", - "expo-dev-client": "~6.0.21", - "expo-device": "~8.0.10", - "expo-document-picker": "~14.0.8", - "expo-file-system": "~19.0.24", - "expo-font": "~14.0.12", - "expo-image": "~3.0.11", - "expo-image-manipulator": "~14.0.8", - "expo-image-picker": "~17.0.11", - "expo-keep-awake": "~15.0.8", - "expo-linking": "~8.0.12", - "expo-localization": "~17.0.9", - "expo-location": "~19.0.8", - "expo-navigation-bar": "~5.0.10", - "expo-notifications": "~0.32.17", - "expo-router": "~6.0.24", - "expo-screen-orientation": "~9.0.9", - "expo-sharing": "~14.0.8", - "expo-splash-screen": "~31.0.13", - "expo-status-bar": "~3.0.9", - "expo-system-ui": "~6.0.9", - "expo-task-manager": "~14.0.9", - "expo-web-browser": "~15.0.11", + "expo": "~57.0.25", + "expo-application": "~57.0.3", + "expo-asset": "~57.0.18", + "expo-audio": "~57.0.5", + "expo-auth-session": "~57.0.13", + "expo-build-properties": "~57.0.22", + "expo-clipboard": "~57.0.2", + "expo-constants": "~57.0.19", + "expo-crypto": "~57.0.3", + "expo-dev-client": "~57.0.19", + "expo-device": "~57.0.2", + "expo-document-picker": "~57.0.2", + "expo-file-system": "~57.0.7", + "expo-font": "~57.0.4", + "expo-image": "~57.0.5", + "expo-image-manipulator": "~57.0.20", + "expo-image-picker": "~57.0.20", + "expo-keep-awake": "~57.0.2", + "expo-linking": "~57.0.11", + "expo-localization": "~57.0.2", + "expo-location": "~57.0.20", + "expo-navigation-bar": "~57.0.2", + "expo-notifications": "~57.0.21", + "expo-router": "~57.0.23", + "expo-screen-orientation": "~57.0.2", + "expo-sharing": "~57.0.22", + "expo-splash-screen": "~57.0.9", + "expo-status-bar": "~57.0.1", + "expo-system-ui": "~57.0.4", + "expo-task-manager": "~57.0.20", + "expo-web-browser": "~57.0.3", "geojson": "~0.5.0", "he": "^1.2.0", "i18next": "~23.14.0", "jwt-decode": "^4.0.0", - "livekit-client": "^2.15.7", + "livekit-client": "^2.20.1", "lodash": "^4.17.21", "lodash.memoize": "~4.1.2", "lucide-react-native": "~0.475.0", "mapbox-gl": "^3.15.0", "moti": "~0.29.0", "nativewind": "5.0.0-preview.4", - "react": "19.1.0", - "react-dom": "19.1.0", + "react": "19.2.3", + "react-dom": "19.2.3", "react-error-boundary": "~4.0.13", "react-hook-form": "~7.53.0", "react-i18next": "~15.0.1", - "react-native": "0.81.5", + "react-native": "0.86.3", "react-native-base64": "~0.2.1", "react-native-ble-manager": "^12.1.5", "react-native-callkeep": "github:Irfanwani/react-native-callkeep#957193d0716f1c2dfdc18e627cbff0f8a0800971", "react-native-css": "^3.0.7", "react-native-edge-to-edge": "1.6.0", "react-native-flash-message": "~0.4.2", - "react-native-gesture-handler": "~2.28.0", - "react-native-keyboard-controller": "1.18.5", + "react-native-gesture-handler": "~2.32.0", + "react-native-keyboard-controller": "1.21.9", "react-native-logs": "~5.3.0", - "react-native-mmkv": "~3.3.0", - "react-native-reanimated": "~4.1.1", + "react-native-mmkv": "3.3.3", + "react-native-reanimated": "4.5.1", "react-native-restart": "0.0.27", - "react-native-safe-area-context": "~5.6.0", - "react-native-screens": "~4.16.0", - "react-native-svg": "15.12.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.0", + "react-native-svg": "15.15.4", "react-native-web": "^0.21.0", - "react-native-webview": "13.15.0", - "react-native-worklets": "0.5.1", + "react-native-webview": "13.16.1", + "react-native-worklets": "0.10.1", "react-query-kit": "~3.3.0", "sanitize-html": "2.17.0", "tailwind-variants": "~0.2.1", @@ -194,10 +193,11 @@ "zustand": "~4.5.5" }, "devDependencies": { - "@babel/core": "~7.26.0", + "@babel/core": "^7.29.0", "@commitlint/cli": "~19.2.2", "@commitlint/config-conventional": "~19.2.2", - "@expo/config": "~12.0.12", + "@expo/config": "^57.0.0", + "@react-native/jest-preset": "~0.86.3", "@tailwindcss/postcss": "^4.2.0", "@testing-library/jest-dom": "~6.5.0", "@testing-library/react-native": "~12.9.0", @@ -206,7 +206,7 @@ "@types/i18n-js": "~3.8.9", "@types/jest": "~29.5.14", "@types/lodash.memoize": "~4.1.9", - "@types/react": "~19.1.10", + "@types/react": "~19.2.14", "@types/react-native-base64": "~0.2.2", "@types/sanitize-html": "^2.16.0", "@typescript-eslint/eslint-plugin": "~5.62.0", @@ -233,18 +233,17 @@ "eslint-plugin-unused-imports": "~2.0.0", "jest": "~29.7.0", "jest-environment-jsdom": "~29.7.0", - "jest-expo": "~54.0.18", + "jest-expo": "~57.0.5", "jest-junit": "~16.0.0", "lint-staged": "~15.2.9", "np": "~10.0.7", - "patch-package": "^8.0.0", + "patch-package": "^8.0.1", "postinstall-postinstall": "^2.1.0", "prettier": "~3.3.3", "react-native-svg-transformer": "~1.5.1", "tailwindcss": "^4.2.0", - "ts-jest": "~29.1.2", "ts-node": "~10.9.2", - "typescript": "~5.9.2", + "typescript": "~6.0.3", "wait-on": "^9.0.3" }, "repository": { @@ -267,12 +266,7 @@ "install": { "exclude": [ "eslint-config-expo", - "react-native@~0.76.6", - "react-native-reanimated@~3.16.1", - "react-native-gesture-handler@~2.20.0", - "react-native-screens@~4.4.0", - "react-native-safe-area-context@~4.12.0", - "react-native-webview@~13.12.5" + "@sentry/react-native" ] } }, @@ -281,6 +275,7 @@ }, "resolutions": { "form-data": "4.0.4", - "lightningcss": "1.30.1" + "lightningcss": "1.30.1", + "react-native-safe-area-context": "5.7.0" } } diff --git a/patches/@rnmapbox+maps+10.1.42-rc.0.patch b/patches/@rnmapbox+maps+10.3.5.patch similarity index 100% rename from patches/@rnmapbox+maps+10.1.42-rc.0.patch rename to patches/@rnmapbox+maps+10.3.5.patch diff --git a/patches/react-native-webview+13.15.0.patch b/patches/react-native-webview+13.16.1.patch similarity index 100% rename from patches/react-native-webview+13.15.0.patch rename to patches/react-native-webview+13.16.1.patch diff --git a/plugins/__tests__/withIconBadge.test.ts b/plugins/__tests__/withIconBadge.test.ts new file mode 100644 index 00000000..048ab7c8 --- /dev/null +++ b/plugins/__tests__/withIconBadge.test.ts @@ -0,0 +1,113 @@ +import type { AppIconBadgeConfig } from 'app-icon-badge/types'; +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import withIconBadge from '../withIconBadge'; + +jest.mock('child_process', () => ({ execFileSync: jest.fn() })); + +interface RenderJob { + dstPath: string; + isAdaptiveIcon: boolean; +} + +interface TestConfig { + _internal: { projectRoot: string }; + icon: string; + ios: { icon: string | Record }; + android: { adaptiveIcon: { foregroundImage: string } }; +} + +const mockExecFileSync = execFileSync as jest.MockedFunction; +const badges: AppIconBadgeConfig['badges'] = [{ text: 'development', type: 'banner', color: 'white' }]; +const outputDir = path.join('.expo', 'app-icon-badge'); + +describe('withIconBadge', () => { + let projectRoot: string; + + const createConfig = (): TestConfig => ({ + _internal: { projectRoot }, + icon: './assets/icon.png', + ios: { icon: './assets/ios-icon.png' }, + android: { adaptiveIcon: { foregroundImage: './assets/adaptive-icon.png' } }, + }); + + const applyPlugin = (config: TestConfig, options: AppIconBadgeConfig): TestConfig => withIconBadge(config, options); + + const getRenderJobs = (call: number): RenderJob[] => JSON.parse((mockExecFileSync.mock.calls[call][1] as string[])[1]); + + beforeEach(() => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'icon-badge-')); + fs.mkdirSync(path.join(projectRoot, 'assets')); + ['icon.png', 'ios-icon.png', 'adaptive-icon.png'].forEach((file) => fs.writeFileSync(path.join(projectRoot, 'assets', file), file)); + + mockExecFileSync.mockReset(); + mockExecFileSync.mockImplementation((_file, args) => { + const jobs: RenderJob[] = JSON.parse((args as string[])[1]); + jobs.forEach((job) => fs.writeFileSync(job.dstPath, 'badged')); + return Buffer.from(''); + }); + }); + + afterEach(() => { + fs.rmSync(projectRoot, { recursive: true, force: true }); + }); + + it('leaves the config untouched when disabled', () => { + const config = applyPlugin(createConfig(), { enabled: false, badges }); + + expect(config.icon).toBe('./assets/icon.png'); + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); + + it('finishes rendering every icon before pointing the config at it', () => { + const config = applyPlugin(createConfig(), { badges }); + const iconPaths = [config.icon, config.ios.icon as string, config.android.adaptiveIcon.foregroundImage]; + + expect(mockExecFileSync).toHaveBeenCalledTimes(1); + expect(getRenderJobs(0).map((job) => job.isAdaptiveIcon)).toEqual([false, false, true]); + expect(new Set(iconPaths).size).toBe(3); + iconPaths.forEach((iconPath) => { + expect(iconPath.startsWith(outputDir)).toBe(true); + expect(fs.readFileSync(path.join(projectRoot, iconPath), 'utf8')).toBe('badged'); + }); + expect(fs.readdirSync(path.join(projectRoot, outputDir)).filter((file) => file.includes('.partial.'))).toEqual([]); + }); + + it('reuses finished icons on repeated config evaluations', () => { + const first = applyPlugin(createConfig(), { badges }); + const second = applyPlugin(createConfig(), { badges }); + + expect(mockExecFileSync).toHaveBeenCalledTimes(1); + expect(second.icon).toBe(first.icon); + expect(second.android.adaptiveIcon.foregroundImage).toBe(first.android.adaptiveIcon.foregroundImage); + }); + + it('re-renders and removes the stale icons when a badge changes', () => { + const first = applyPlugin(createConfig(), { badges }); + const second = applyPlugin(createConfig(), { badges: [{ ...badges[0], text: 'staging' }] }); + + expect(mockExecFileSync).toHaveBeenCalledTimes(2); + expect(second.icon).not.toBe(first.icon); + expect(fs.existsSync(path.join(projectRoot, first.icon))).toBe(false); + expect(fs.existsSync(path.join(projectRoot, second.icon))).toBe(true); + }); + + it('throws rather than pointing at an unfinished icon when rendering fails', () => { + mockExecFileSync.mockImplementation(() => { + throw new Error('render failed'); + }); + + expect(() => applyPlugin(createConfig(), { badges })).toThrow('render failed'); + }); + + it('leaves non-path icon values alone', () => { + const iosIcon = { light: './assets/ios-icon.png' }; + const config = applyPlugin({ ...createConfig(), ios: { icon: iosIcon } }, { badges }); + + expect(config.ios.icon).toBe(iosIcon); + expect(getRenderJobs(0)).toHaveLength(2); + }); +}); diff --git a/plugins/__tests__/withResourceBundleDeploymentTarget.test.ts b/plugins/__tests__/withResourceBundleDeploymentTarget.test.ts new file mode 100644 index 00000000..294b86be --- /dev/null +++ b/plugins/__tests__/withResourceBundleDeploymentTarget.test.ts @@ -0,0 +1,28 @@ +import { addResourceBundleDeploymentTarget } from '../withResourceBundleDeploymentTarget'; + +const podfile = `target 'ResgridDispatch' do + post_install do |installer| + react_native_post_install(installer) + end +end +`; + +describe('addResourceBundleDeploymentTarget', () => { + it('raises resource bundles inside the post_install hook', () => { + const lines = addResourceBundleDeploymentTarget(podfile).split('\n'); + const hookLine = lines.findIndex((line) => line.includes('post_install do |installer|')); + + expect(lines[hookLine + 1]).toContain('@generated begin resgrid-resource-bundle-deployment-target'); + expect(lines.join('\n')).toContain('result.resource_bundle_targets.each'); + expect(lines.join('\n')).toContain("build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = min_ios_version_supported"); + expect(lines.findIndex((line) => line.includes('react_native_post_install'))).toBeGreaterThan(hookLine); + }); + + it('only adds the block once across repeated prebuilds', () => { + const once = addResourceBundleDeploymentTarget(podfile); + const twice = addResourceBundleDeploymentTarget(once); + + expect(twice).toBe(once); + expect(twice.match(/resource_bundle_targets/g)).toHaveLength(1); + }); +}); diff --git a/plugins/withIconBadge.js b/plugins/withIconBadge.js new file mode 100644 index 00000000..6b836908 --- /dev/null +++ b/plugins/withIconBadge.js @@ -0,0 +1,118 @@ +/* eslint-env node */ +const { execFileSync } = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +/** + * Stamps the environment/version badges onto the app icons for non-production builds. + * + * Replaces the `app-icon-badge` config plugin, which starts its icon writes without ever awaiting + * them. Expo registers its own icon mods after project plugins, so they run first and read the + * badged PNGs while a write — or the truncation from one of prebuild's repeated config evaluations — + * is still in flight, and jimp fails with "Could not find MIME for Buffer ". + * + * Config plugins are synchronous, so the badges are rendered in a child process that is waited on. + * Output names carry a hash of their inputs, so repeat evaluations reuse the finished files, and each + * file is renamed into place only once the render has fully completed. + */ + +const OUTPUT_DIR = path.join('.expo', 'app-icon-badge'); +const RENDERER_VERSION = require('app-icon-badge/package.json').version; + +function getIconTargets(config) { + return [ + { + name: 'icon', + source: config.icon, + isAdaptiveIcon: false, + apply: (iconPath) => { + config.icon = iconPath; + }, + }, + { + name: 'ios-icon', + source: config.ios?.icon, + isAdaptiveIcon: false, + apply: (iconPath) => { + config.ios.icon = iconPath; + }, + }, + { + name: 'adaptive-icon', + source: config.android?.adaptiveIcon?.foregroundImage, + isAdaptiveIcon: true, + apply: (iconPath) => { + config.android.adaptiveIcon.foregroundImage = iconPath; + }, + }, + ].filter((target) => typeof target.source === 'string'); +} + +function getOutputPath(projectRoot, target, badges) { + const hash = crypto + .createHash('sha256') + .update(fs.readFileSync(path.resolve(projectRoot, target.source))) + .update(JSON.stringify({ badges, isAdaptiveIcon: target.isAdaptiveIcon, renderer: RENDERER_VERSION })) + .digest('hex') + .slice(0, 12); + + return path.join(OUTPUT_DIR, `${target.name}-${hash}.png`); +} + +function renderBadges(projectRoot, targets, badges) { + const outputDir = path.resolve(projectRoot, OUTPUT_DIR); + fs.mkdirSync(outputDir, { recursive: true }); + + const jobs = targets.map((target) => { + const finalPath = path.resolve(projectRoot, target.output); + return { + icon: path.resolve(projectRoot, target.source), + dstPath: finalPath.replace(/\.png$/, '.partial.png'), + finalPath, + badges, + isAdaptiveIcon: target.isAdaptiveIcon, + }; + }); + + // addBadge() does not await its own write, so the files are only complete once the child exits. + execFileSync(process.execPath, [__filename, JSON.stringify(jobs)], { stdio: ['ignore', 'ignore', 'inherit'] }); + + jobs.forEach((job) => fs.renameSync(job.dstPath, job.finalPath)); + + targets.forEach((target) => { + const current = path.basename(target.output); + fs.readdirSync(outputDir) + .filter((file) => file.startsWith(`${target.name}-`) && file !== current) + .forEach((file) => fs.rmSync(path.join(outputDir, file), { force: true })); + }); +} + +const withIconBadge = (config, { enabled = true, badges = [] } = {}) => { + if (!enabled || badges.length === 0) { + return config; + } + + const projectRoot = config._internal?.projectRoot ?? process.cwd(); + const targets = getIconTargets(config).map((target) => ({ ...target, output: getOutputPath(projectRoot, target, badges) })); + const pending = targets.filter((target) => !fs.existsSync(path.resolve(projectRoot, target.output))); + + if (pending.length > 0) { + renderBadges(projectRoot, pending, badges); + } + + targets.forEach((target) => target.apply(target.output)); + return config; +}; + +module.exports = withIconBadge; + +if (require.main === module) { + const { addBadge } = require('app-icon-badge'); + const jobs = JSON.parse(process.argv[2]); + + Promise.all(jobs.map(({ icon, dstPath, badges, isAdaptiveIcon }) => addBadge({ icon, dstPath, badges, isAdaptiveIcon }))).catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/plugins/withResourceBundleDeploymentTarget.js b/plugins/withResourceBundleDeploymentTarget.js new file mode 100644 index 00000000..98f4f967 --- /dev/null +++ b/plugins/withResourceBundleDeploymentTarget.js @@ -0,0 +1,43 @@ +/* eslint-env node */ +const { withPodfile } = require('@expo/config-plugins'); +const { mergeContents } = require('@expo/config-plugins/build/utils/generateCode'); + +/** + * Raises the iOS deployment target of CocoaPods resource-bundle targets to React Native's floor. + * + * `react_native_post_install` lifts every pod's native target to `min_ios_version_supported`, but + * skips the resource bundles that pods such as MapboxMaps, RNSVG and react-native-permissions + * declare. Those keep their podspec minimum (12.4 / 14.0), which Xcode 27 rejects outright — its + * supported range starts at iOS 15.0 — failing the build with "IPHONEOS_DEPLOYMENT_TARGET is set to 12.4". + */ + +const POST_INSTALL_BLOCK = ` installer.target_installation_results.pod_target_installation_results.each_value do |result| + result.resource_bundle_targets.each do |bundle_target| + bundle_target.build_configurations.each do |build_config| + deployment_target = build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + next if deployment_target.nil? || deployment_target.to_f >= min_ios_version_supported.to_f + + build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = min_ios_version_supported + end + end + end`; + +function addResourceBundleDeploymentTarget(podfile) { + return mergeContents({ + tag: 'resgrid-resource-bundle-deployment-target', + src: podfile, + newSrc: POST_INSTALL_BLOCK, + anchor: /^\s*post_install do \|installer\|/, + offset: 1, + comment: '#', + }).contents; +} + +const withResourceBundleDeploymentTarget = (config) => + withPodfile(config, (podfileConfig) => { + podfileConfig.modResults.contents = addResourceBundleDeploymentTarget(podfileConfig.modResults.contents); + return podfileConfig; + }); + +module.exports = withResourceBundleDeploymentTarget; +module.exports.addResourceBundleDeploymentTarget = addResourceBundleDeploymentTarget; diff --git a/src/__tests__/app/call/[id].security.test.tsx b/src/__tests__/app/call/[id].security.test.tsx index 2bbd0be5..231dea3e 100644 --- a/src/__tests__/app/call/[id].security.test.tsx +++ b/src/__tests__/app/call/[id].security.test.tsx @@ -41,6 +41,10 @@ jest.mock('expo-router', () => ({ Stack: { Screen: ({ children, ...props }: any) =>
{children}
, }, + useFocusEffect: jest.fn((callback: () => void) => { + const React = require('react'); + React.useEffect(callback, []); + }), })); // Mock expo-constants @@ -172,13 +176,6 @@ jest.mock('nativewind', () => ({ })); // Mock navigation hook -jest.mock('@react-navigation/native', () => ({ - useFocusEffect: jest.fn((callback: () => void) => { - const React = require('react'); - React.useEffect(callback, []); - }), -})); - // Mock components jest.mock('@/components/common/loading', () => ({ Loading: () =>
Loading...
, diff --git a/src/__tests__/app/call/[id].test.tsx b/src/__tests__/app/call/[id].test.tsx index 871e5528..8656467e 100644 --- a/src/__tests__/app/call/[id].test.tsx +++ b/src/__tests__/app/call/[id].test.tsx @@ -175,6 +175,9 @@ jest.mock('expo-router', () => ({ back: jest.fn(), push: jest.fn(), })), + useIsFocused: jest.fn(() => true), + useNavigation: jest.fn(), + useFocusEffect: jest.fn(), })); // Mock Lucide React Native icons @@ -404,13 +407,6 @@ jest.mock('expo-navigation-bar', () => ({ getBehaviorAsync: jest.fn(), })); -// Mock @react-navigation/native -jest.mock('@react-navigation/native', () => ({ - useIsFocused: jest.fn(() => true), - useNavigation: jest.fn(), - useFocusEffect: jest.fn(), -})); - // Mock react-native-edge-to-edge jest.mock('react-native-edge-to-edge', () => ({ SystemBars: { diff --git a/src/__tests__/app/calls.test.tsx b/src/__tests__/app/calls.test.tsx index d7420e88..c9e57510 100644 --- a/src/__tests__/app/calls.test.tsx +++ b/src/__tests__/app/calls.test.tsx @@ -25,6 +25,11 @@ jest.mock('expo-router', () => ({ router: { push: jest.fn(), }, + useFocusEffect: jest.fn((callback: () => void) => { + const React = require('react'); + React.useEffect(callback, []); + }), + useIsFocused: jest.fn(() => true), })); // Mock storage @@ -205,14 +210,6 @@ jest.mock('@/components/ui/focus-aware-status-bar', () => ({ })); // Mock useFocusEffect and useIsFocused -jest.mock('@react-navigation/native', () => ({ - useFocusEffect: jest.fn((callback: () => void) => { - const React = require('react'); - React.useEffect(callback, []); - }), - useIsFocused: jest.fn(() => true), -})); - import CallsScreen from '../../app/(app)/calls'; describe('CallsScreen', () => { diff --git a/src/__tests__/app/index.test.tsx b/src/__tests__/app/index.test.tsx index f762abb5..4f2793ba 100644 --- a/src/__tests__/app/index.test.tsx +++ b/src/__tests__/app/index.test.tsx @@ -14,7 +14,10 @@ jest.mock('react-native-safe-area-context', () => ({ jest.mock('@/hooks/use-app-lifecycle'); jest.mock('@/stores/app/location-store'); jest.mock('@/hooks/use-map-signalr-updates', () => ({ - useMapSignalRUpdates: jest.fn(), + useMapSignalRUpdates: jest.fn(() => ({ requestRefresh: jest.fn() })), +})); +jest.mock('@/hooks/use-map-live-locations', () => ({ + useMapLiveLocations: jest.fn(() => ({ applyToFetchedPins: (pins: unknown[]) => pins })), })); jest.mock('@/hooks/use-map-layers', () => ({ useMapLayers: jest.fn(() => ({ @@ -30,15 +33,6 @@ jest.mock('@/hooks/use-map-layers', () => ({ })), MapLayerType: { ALL: 'ALL' }, })); -jest.mock('@react-navigation/native', () => ({ - useIsFocused: jest.fn(() => true), - useNavigation: jest.fn(() => ({ - navigate: jest.fn(), - push: jest.fn(), - replace: jest.fn(), - goBack: jest.fn(), - })), -})); jest.mock('@/api/mapping/mapping', () => ({ getMapDataAndMarkers: jest.fn().mockResolvedValue({ Data: { MapMakerInfos: [] }, @@ -71,6 +65,13 @@ jest.mock('expo-router', () => ({ useFocusEffect: jest.fn(() => { // Don't call the callback to prevent infinite loops in tests }), + useIsFocused: jest.fn(() => true), + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + push: jest.fn(), + replace: jest.fn(), + goBack: jest.fn(), + })), })); jest.mock('react-i18next', () => ({ useTranslation: () => ({ diff --git a/src/__tests__/app/map.web.test.tsx b/src/__tests__/app/map.web.test.tsx new file mode 100644 index 00000000..a0293e39 --- /dev/null +++ b/src/__tests__/app/map.web.test.tsx @@ -0,0 +1,193 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { getMapDataAndMarkers } from '@/api/mapping/mapping'; +import { LIVE_LOCATION_REFRESH_DELAY_MS } from '@/hooks/use-map-live-locations'; +import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; +import { useSignalRStore } from '@/stores/signalr/signalr-store'; + +import MapWeb from '../../app/(app)/map.web'; + +// Minimal mapbox-gl stand-in that records what the screen does to the map and its markers. +interface MockMarker { + pinId: string; + lngLat: [number, number]; + setLngLat: jest.Mock; + remove: jest.Mock; +} +interface MockMap { + handlers: Record void>; + flyTo: jest.Mock; +} +const mockMarkers: MockMarker[] = []; +const mockMaps: MockMap[] = []; + +jest.mock('mapbox-gl', () => { + class Popup { + setHTML = jest.fn(() => this); + } + class Marker { + pinId: string; + lngLat: [number, number] = [0, 0]; + popup: Popup | undefined; + setLngLat = jest.fn((lngLat: [number, number]) => { + this.lngLat = lngLat; + return this; + }); + remove = jest.fn(); + constructor(options: { element: { pinId: string } }) { + this.pinId = options.element.pinId; + mockMarkers.push(this as unknown as MockMarker); + } + setPopup(popup: Popup) { + this.popup = popup; + return this; + } + getPopup() { + return this.popup; + } + addTo() { + return this; + } + } + class MapboxMap { + handlers: Record void> = {}; + flyTo = jest.fn(); + constructor() { + mockMaps.push(this as unknown as MockMap); + } + addControl() {} + on(event: string, handler: () => void) { + this.handlers[event] = handler; + } + once() {} + off() {} + remove() {} + setStyle() {} + getLayer() { + return undefined; + } + getSource() { + return undefined; + } + addSource() {} + addLayer() {} + } + class Control {} + return { __esModule: true, default: { Map: MapboxMap, Marker, Popup, NavigationControl: Control, GeolocateControl: Control, accessToken: '' } }; +}); + +jest.mock('@/stores/signalr/signalr-store', () => { + const { create } = jest.requireActual('zustand'); + return { useSignalRStore: create(() => ({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 })) }; +}); + +jest.mock('@/api/mapping/mapping', () => ({ getMapDataAndMarkers: jest.fn() })); +jest.mock('@/lib/map-markers-web', () => ({ createMapMarkerElement: jest.fn((pin: { Id: string }) => ({ pinId: pin.Id })) })); +jest.mock('@/lib/map-center', () => ({ getDepartmentMapCenter: () => ({ latitude: 39, longitude: -119, zoomLevel: 9 }) })); +jest.mock('@/lib/env', () => ({ Env: { MAPBOX_PUBKEY: 'pk.test' } })); +jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() } })); +jest.mock('@/stores/app/location-store', () => ({ useLocationStore: (selector: (state: object) => unknown) => selector({ latitude: null, longitude: null }) })); +jest.mock('nativewind', () => ({ useColorScheme: () => ({ colorScheme: 'light' }) })); +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('expo-router', () => ({ Stack: { Screen: () => null }, useFocusEffect: jest.fn() })); +jest.mock('@/components/ui/focus-aware-status-bar', () => ({ FocusAwareStatusBar: () => null })); +jest.mock('@/hooks/use-active-map-layers', () => ({ useActiveMapLayers: () => ({ activeLayers: [] }) })); +jest.mock('@/hooks/use-analytics', () => ({ useAnalytics: () => ({ trackEvent: jest.fn() }) })); +jest.mock('@/hooks/use-map-layers', () => { + const layerState = { + layers: [], + visibleLayers: new Set(), + isLoading: false, + fetchLayers: () => {}, + toggleLayer: () => {}, + showAllLayers: () => {}, + hideAllLayers: () => {}, + getVisibleLayerData: () => [], + }; + return { useMapLayers: () => layerState, MapLayerType: { ALL: 'ALL' } }; +}); + +const mockGetMapDataAndMarkers = getMapDataAndMarkers as jest.MockedFunction; + +const makePin = (id: string, type: number, latitude: number, longitude: number, poiTypeId: number | null = null) => + ({ Id: id, Type: type, Latitude: latitude, Longitude: longitude, Title: id, PoiTypeId: poiTypeId }) as MapMakerInfoData; + +const HYDRANTS = { PoiTypeId: 7, Name: 'Hydrants', Color: '#f00', ImagePath: '', PoiImage: '', Marker: '', IsDestination: false }; + +const mapResponse = (pins: MapMakerInfoData[]) => ({ Data: { MapMakerInfos: pins, CenterLat: '39.5', CenterLon: '-119.8', ZoomLevel: '11', PoiLayers: [HYDRANTS] } }) as any; + +const REST_PINS = [makePin('c1', 0, 39.1, -119.1), makePin('u12', 1, 39.2, -119.2), makePin('pABC', 3, 39.3, -119.3), makePin('poi5', 4, 39.4, -119.4, 7)]; + +const liveMarker = (pinId: string) => mockMarkers.filter((marker) => marker.pinId === pinId && marker.remove.mock.calls.length === 0); + +describe('Map screen (web) realtime locations', () => { + const originalDocument = (global as any).document; + + beforeEach(() => { + jest.useFakeTimers(); + mockMarkers.length = 0; + mockMaps.length = 0; + mockGetMapDataAndMarkers.mockReset(); + mockGetMapDataAndMarkers.mockResolvedValue(mapResponse(REST_PINS)); + useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 }); + // Only the stylesheet injection touches document; report it as already present. + (global as any).document = { getElementById: () => ({}) }; + }); + + afterEach(() => { + (global as any).document = originalDocument; + jest.useRealTimers(); + }); + + const renderLoadedMap = async () => { + render(, { createNodeMock: () => ({}) }); + act(() => mockMaps[0].handlers.load()); + await waitFor(() => expect(mockMarkers).toHaveLength(REST_PINS.length)); + }; + + const pushUnknownPin = () => { + act(() => { + useSignalRStore.setState({ liveLocations: { u99: { pinId: 'u99', latitude: 38, longitude: -118, timestamp: null, receivedAt: Date.now() } } }); + }); + }; + + it('moves a pushed pin in place and never re-centres the camera', async () => { + await renderLoadedMap(); + expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1); + + act(() => { + useSignalRStore.setState({ liveLocations: { pabc: { pinId: 'pabc', latitude: 40, longitude: -120, timestamp: null, receivedAt: Date.now() } } }); + }); + + const [personMarker] = liveMarker('pABC'); + expect(personMarker.setLngLat).toHaveBeenLastCalledWith([-120, 40]); + expect(mockMarkers).toHaveLength(REST_PINS.length); + mockMarkers.forEach((marker) => expect(marker.remove).not.toHaveBeenCalled()); + + pushUnknownPin(); + await act(async () => { + jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS); + }); + await waitFor(() => expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(2)); + expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1); + }); + + it("keeps the user's POI layer toggles across a background refetch", async () => { + await renderLoadedMap(); + + fireEvent.press(screen.getByTestId('layers-button')); + fireEvent.press(screen.getByText('Hydrants')); + expect(liveMarker('poi5')).toHaveLength(0); + + pushUnknownPin(); + await act(async () => { + jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS); + }); + await waitFor(() => expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(2)); + await act(async () => {}); + + expect(liveMarker('poi5')).toHaveLength(0); + expect(liveMarker('u12')).toHaveLength(1); + }); +}); diff --git a/src/__tests__/app/root/lockscreen.test.tsx b/src/__tests__/app/root/lockscreen.test.tsx index 10859393..9019bd4e 100644 --- a/src/__tests__/app/root/lockscreen.test.tsx +++ b/src/__tests__/app/root/lockscreen.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react-native'; -import { NavigationContainer } from '@react-navigation/native'; import { useRouter } from 'expo-router'; import Lockscreen from '../../../app/lockscreen'; @@ -11,6 +10,7 @@ import useLockscreenStore from '@/stores/lockscreen/store'; // Mock dependencies jest.mock('expo-router', () => ({ useRouter: jest.fn(), + useIsFocused: jest.fn(() => true), })); jest.mock('react-i18next', () => ({ @@ -38,7 +38,7 @@ jest.mock('@/lib/auth/api', () => ({ jest.mock('@/stores/lockscreen/store'); const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { - return {children}; + return <>{children}; }; describe('Lockscreen', () => { @@ -122,7 +122,7 @@ describe('Lockscreen', () => { expect(passwordInput.props.type).toBe('password'); // Find all pressable elements and get the eye icon toggle (it's inside InputSlot) - const allElements = root.findAllByType('View'); + const allElements = root.findAll((node: any) => node.type === 'View'); const inputSlot = allElements.find((el: any) => el.props.className?.includes('pr-3')); // Trigger the press on the InputSlot which has the onPress handler diff --git a/src/__tests__/app/root/maintenance.test.tsx b/src/__tests__/app/root/maintenance.test.tsx index c101589a..f354e81b 100644 --- a/src/__tests__/app/root/maintenance.test.tsx +++ b/src/__tests__/app/root/maintenance.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react-native'; -import { NavigationContainer } from '@react-navigation/native'; import { useRouter } from 'expo-router'; import Maintenance from '../../../app/maintenance'; @@ -9,6 +8,7 @@ import { Env } from '@/lib/env'; // Mock dependencies jest.mock('expo-router', () => ({ useRouter: jest.fn(), + useIsFocused: jest.fn(() => true), })); jest.mock('react-i18next', () => ({ @@ -31,7 +31,7 @@ jest.mock('@/lib/env', () => ({ })); const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { - return {children}; + return <>{children}; }; describe('Maintenance', () => { diff --git a/src/api/config/__tests__/index.test.ts b/src/api/config/__tests__/index.test.ts new file mode 100644 index 00000000..b322545e --- /dev/null +++ b/src/api/config/__tests__/index.test.ts @@ -0,0 +1,38 @@ +const mockAxiosGet = jest.fn(); + +jest.mock('axios', () => ({ + __esModule: true, + default: { get: (...args: unknown[]) => mockAxiosGet(...args) }, +})); +jest.mock('@/lib/storage/app', () => ({ + getBaseApiUrl: () => 'https://api-eu-central.resgrid.com/api/v4', +})); +jest.mock('../../common', () => ({ + createApiEndpoint: () => ({ get: jest.fn() }), +})); + +import { getSystemConfig } from '../index'; + +describe('getSystemConfig', () => { + beforeEach(() => { + mockAxiosGet.mockReset(); + mockAxiosGet.mockResolvedValue({ data: { Data: { Locations: [] } } }); + }); + + it('calls the configured server anonymously so it works before sign-in', async () => { + const result = await getSystemConfig(); + + expect(result).toEqual({ Data: { Locations: [] } }); + expect(mockAxiosGet).toHaveBeenCalledTimes(1); + const [url, config] = mockAxiosGet.mock.calls[0]; + expect(url).toBe('https://api-eu-central.resgrid.com/api/v4/Config/GetSystemConfig'); + expect(config.headers?.Authorization).toBeUndefined(); + expect(config.timeout).toBeGreaterThan(0); + }); + + it('can query an explicit server', async () => { + await getSystemConfig('https://api.resgrid.com/api/v4'); + + expect(mockAxiosGet.mock.calls[0][0]).toBe('https://api.resgrid.com/api/v4/Config/GetSystemConfig'); + }); +}); diff --git a/src/api/config/index.ts b/src/api/config/index.ts index c2180396..f7eec994 100644 --- a/src/api/config/index.ts +++ b/src/api/config/index.ts @@ -1,10 +1,15 @@ +import axios from 'axios'; + +import { getBaseApiUrl } from '@/lib/storage/app'; import { type GetConfigResult } from '@/models/v4/configs/getConfigResult'; import { type GetSystemConfigResult } from '@/models/v4/configs/getSystemConfigResult'; import { createApiEndpoint } from '../common'; +// A custom server URL that doesn't answer shouldn't leave the server picker spinning. +const SYSTEM_CONFIG_TIMEOUT_MS = 10000; + const getConfigApi = createApiEndpoint('/Config/GetConfig'); -const getSystemConfigApi = createApiEndpoint('/Config/GetSystemConfig'); export const getConfig = async (key: string) => { const response = await getConfigApi.get({ @@ -13,7 +18,10 @@ export const getConfig = async (key: string) => { return response.data; }; -export const getSystemConfig = async () => { - const response = await getSystemConfigApi.get(); +// GetSystemConfig is anonymous and is needed on the login screen (to list the Resgrid hosted +// sites) before there is a session, so it bypasses the authenticated api client - that client +// refuses to send any request without an access token. +export const getSystemConfig = async (baseApiUrl: string = getBaseApiUrl()) => { + const response = await axios.get(`${baseApiUrl}/Config/GetSystemConfig`, { timeout: SYSTEM_CONFIG_TIMEOUT_MS }); return response.data; }; diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 34905fe1..debe8cf8 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -57,6 +57,7 @@ async function teardownSignedInSession(): Promise { ['SignalR update hub', () => signalR.disconnectUpdateHub()], ['SignalR chat hub', () => signalR.disconnectChatHub()], ['SignalR geolocation hub', () => signalR.disconnectGeolocationHub()], + ['realtime map locations', () => signalR.clearLiveLocations()], ['LiveKit room', () => useLiveKitStore.getState().disconnectFromRoom()], ['audio stream', () => useAudioStreamStore.getState().cleanup()], ['audio service', () => audioService.cleanup()], @@ -246,6 +247,24 @@ export default function TabLayout() { if (!isCurrentRun()) return; + // The dispatch console's live maps move unit and personnel pins from the geolocation hub's feed, so it + // connects with the update hub (and, like it, disconnects on background and reconnects on resume). + try { + await useSignalRStore.getState().connectGeolocationHub(); + logger.info({ + message: 'SignalR geolocation hub connected successfully', + context: { platform: Platform.OS }, + }); + } catch (error) { + logger.error({ + message: 'Failed to connect SignalR geolocation hub during initialization', + context: { error, platform: Platform.OS }, + }); + // Don't fail initialization if SignalR connection fails + } + + if (!isCurrentRun()) return; + // Connect the realtime chat hub only when the Chat.System feature flag is on for // this department; when it is off every chat surface stays hidden. if (featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) { diff --git a/src/app/(app)/calls.tsx b/src/app/(app)/calls.tsx index 6a8b891a..46b1066c 100644 --- a/src/app/(app)/calls.tsx +++ b/src/app/(app)/calls.tsx @@ -1,5 +1,4 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { type Href, router } from 'expo-router'; +import { type Href, router, useFocusEffect } from 'expo-router'; import { PlusIcon, RefreshCcwDotIcon, Search, X } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/app/(app)/home.tsx b/src/app/(app)/home.tsx index 2292fac1..06c6ddad 100644 --- a/src/app/(app)/home.tsx +++ b/src/app/(app)/home.tsx @@ -1,5 +1,4 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { type Href, router } from 'expo-router'; +import { type Href, router, useFocusEffect } from 'expo-router'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/app/(app)/home.web.tsx b/src/app/(app)/home.web.tsx index 1ffc1201..dbf29e57 100644 --- a/src/app/(app)/home.web.tsx +++ b/src/app/(app)/home.web.tsx @@ -1,5 +1,4 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { type Href, router } from 'expo-router'; +import { type Href, router, useFocusEffect } from 'expo-router'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/app/(app)/map.tsx b/src/app/(app)/map.tsx index 528c1f23..15b76fad 100644 --- a/src/app/(app)/map.tsx +++ b/src/app/(app)/map.tsx @@ -16,10 +16,12 @@ import { useActiveMapLayers } from '@/hooks/use-active-map-layers'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAppLifecycle } from '@/hooks/use-app-lifecycle'; import { MapLayerType, useMapLayers } from '@/hooks/use-map-layers'; +import { useMapLiveLocations } from '@/hooks/use-map-live-locations'; import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; -import { createDefaultVisiblePoiLayerIds, filterMapPinsByPoiLayers, getPoiMapLayerId } from '@/lib/poi-map-layers'; +import { getPinEntityId } from '@/lib/map-pin-ids'; +import { createDefaultVisiblePoiLayerIds, filterMapPinsByPoiLayers, getPoiMapLayerId, mergeVisiblePoiLayerIds } from '@/lib/poi-map-layers'; import { onSortOptions } from '@/lib/utils'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { type GetMapLayersData } from '@/models/v4/mapping/getMapLayersResultData'; @@ -124,12 +126,28 @@ export default function Map() { const pulseAnim = useRef(new Animated.Value(1)).current; + // The POI layers last synced from map data; null until the first load. Background refetches (update-hub + // events, realtime locations) must keep the user's layer toggles, so only the first load applies defaults. + const syncedPoiLayersRef = useRef(null); const syncPoiLayers = useCallback((nextPoiLayers: PoiLayerData[]) => { + const previousPoiLayers = syncedPoiLayersRef.current; + syncedPoiLayersRef.current = nextPoiLayers; setPoiLayers(nextPoiLayers); - setVisiblePoiLayerIds(createDefaultVisiblePoiLayerIds(nextPoiLayers)); + setVisiblePoiLayerIds((currentLayerIds) => (previousPoiLayers === null ? createDefaultVisiblePoiLayerIds(nextPoiLayers) : mergeVisiblePoiLayerIds(currentLayerIds, previousPoiLayers, nextPoiLayers))); }, []); - useMapSignalRUpdates(setMapPins, syncPoiLayers); + // Realtime unit/personnel positions from the geolocation hub. Its refetch requests go through the + // update-hub refetch below, which is wired in once that hook has run. + const requestPinsRefreshRef = useRef<(() => void) | null>(null); + const requestPinsRefresh = useCallback(() => requestPinsRefreshRef.current?.(), []); + const { applyToFetchedPins } = useMapLiveLocations({ pins: mapPins, setPins: setMapPins, requestRefresh: requestPinsRefresh }); + + const handleMarkersFetched = useCallback((markers: MapMakerInfoData[], fetchStartedAt: number) => setMapPins(applyToFetchedPins(markers, fetchStartedAt)), [applyToFetchedPins]); + + const { requestRefresh } = useMapSignalRUpdates(handleMarkersFetched, syncPoiLayers); + useEffect(() => { + requestPinsRefreshRef.current = requestRefresh; + }, [requestRefresh]); const togglePoiLayer = useCallback((layerId: string) => { setVisiblePoiLayerIds((currentLayerIds) => { @@ -288,10 +306,11 @@ export default function Map() { const fetchMapDataAndMarkers = async () => { try { + const fetchStartedAt = Date.now(); const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal); if (mapDataAndMarkers && mapDataAndMarkers.Data) { - setMapPins(mapDataAndMarkers.Data.MapMakerInfos); + handleMarkersFetched(mapDataAndMarkers.Data.MapMakerInfos, fetchStartedAt); syncPoiLayers(mapDataAndMarkers.Data.PoiLayers ?? []); } } catch (error) { @@ -316,7 +335,7 @@ export default function Map() { return () => { abortController.abort(); }; - }, [syncPoiLayers]); + }, [handleMarkersFetched, syncPoiLayers]); useEffect(() => { Animated.loop( @@ -384,19 +403,19 @@ export default function Map() { logger.info({ message: 'Setting call as current call', context: { - callId: pin.Id, + callId: getPinEntityId(pin), callTitle: pin.Title, }, }); - await useCoreStore.getState().setActiveCall(pin.Id); + await useCoreStore.getState().setActiveCall(getPinEntityId(pin)); useToastStore.getState().showToast('success', t('map.call_set_as_current')); } catch (error) { logger.error({ message: 'Failed to set call as current call', context: { error, - callId: pin.Id, + callId: getPinEntityId(pin), callTitle: pin.Title, }, }); diff --git a/src/app/(app)/map.web.tsx b/src/app/(app)/map.web.tsx index 41cbad84..9dc3c22e 100644 --- a/src/app/(app)/map.web.tsx +++ b/src/app/(app)/map.web.tsx @@ -11,12 +11,13 @@ import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { useActiveMapLayers } from '@/hooks/use-active-map-layers'; import { useAnalytics } from '@/hooks/use-analytics'; import { MapLayerType, useMapLayers } from '@/hooks/use-map-layers'; +import { useMapLiveLocations } from '@/hooks/use-map-live-locations'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; import { getDepartmentMapCenter } from '@/lib/map-center'; import { getMapPinSummary, hasValidMapCoordinates } from '@/lib/map-markers'; import { createMapMarkerElement } from '@/lib/map-markers-web'; -import { createDefaultVisiblePoiLayerIds, filterMapPinsByPoiLayers, getPoiMapLayerId } from '@/lib/poi-map-layers'; +import { createDefaultVisiblePoiLayerIds, filterMapPinsByPoiLayers, getPoiMapLayerId, mergeVisiblePoiLayerIds } from '@/lib/poi-map-layers'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { type GetMapLayersData } from '@/models/v4/mapping/getMapLayersResultData'; import { type PoiLayerData } from '@/models/v4/mapping/poiLayerData'; @@ -31,7 +32,9 @@ export default function MapWeb() { const { colorScheme } = useColorScheme(); const mapContainer = useRef(null); const map = useRef(null); - const markersRef = useRef([]); + // Live markers by pin id, so a moved pin is repositioned in place instead of every marker being rebuilt. + const markersRef = useRef>(new Map()); + const markerMetaRef = useRef>(new Map()); const layerIdsRef = useRef([]); const sourceIdsRef = useRef([]); const activeSourceIdsRef = useRef([]); @@ -53,11 +56,21 @@ export default function MapWeb() { // Custom-map region layers (RE1-T105) rendered on top of the legacy vector layers. const { activeLayers } = useActiveMapLayers(); + // The POI layers last synced from map data; null until the first load. A background refetch must keep the + // user's layer toggles, so only the first load applies defaults. + const syncedPoiLayersRef = useRef(null); const syncPoiLayers = useCallback((nextPoiLayers: PoiLayerData[]) => { + const previousPoiLayers = syncedPoiLayersRef.current; + syncedPoiLayersRef.current = nextPoiLayers; setPoiLayers(nextPoiLayers); - setVisiblePoiLayerIds(createDefaultVisiblePoiLayerIds(nextPoiLayers)); + setVisiblePoiLayerIds((currentLayerIds) => (previousPoiLayers === null ? createDefaultVisiblePoiLayerIds(nextPoiLayers) : mergeVisiblePoiLayerIds(currentLayerIds, previousPoiLayers, nextPoiLayers))); }, []); + // Realtime unit/personnel positions from the geolocation hub; its refetch requests reuse fetchMapData below. + const requestPinsRefreshRef = useRef<(() => void) | null>(null); + const requestPinsRefresh = useCallback(() => requestPinsRefreshRef.current?.(), []); + const { applyToFetchedPins } = useMapLiveLocations({ pins: mapPins, setPins: setMapPins, requestRefresh: requestPinsRefresh }); + const togglePoiLayer = useCallback((layerId: string) => { setVisiblePoiLayerIds((currentLayerIds) => { const nextLayerIds = new Set(currentLayerIds); @@ -146,10 +159,14 @@ export default function MapWeb() { }); }); + const markers = markersRef.current; + const markerMeta = markerMetaRef.current; + return () => { // Clean up markers - markersRef.current.forEach((marker) => marker.remove()); - markersRef.current = []; + markers.forEach((marker) => marker.remove()); + markers.clear(); + markerMeta.clear(); map.current?.remove(); map.current = null; @@ -184,17 +201,29 @@ export default function MapWeb() { }, [fetchLayers]) ); - // Fetch map data and markers on mount - useEffect(() => { + // Fetches map data and markers: once on mount, and again in the background when the realtime location feed + // asks for it. Only the first successful load may move the camera; a refresh never touches the user's view. + const fetchAbortRef = useRef(null); + const hasCenteredOnDataRef = useRef(false); + const fetchMapData = useCallback(async () => { + fetchAbortRef.current?.abort(); const abortController = new AbortController(); + fetchAbortRef.current = abortController; + + try { + const fetchStartedAt = Date.now(); + const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal); - const fetchMapDataAndMarkers = async () => { - try { - const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal); + if (abortController.signal.aborted) { + return; + } - if (mapDataAndMarkers && mapDataAndMarkers.Data) { - setMapPins(mapDataAndMarkers.Data.MapMakerInfos); - syncPoiLayers(mapDataAndMarkers.Data.PoiLayers ?? []); + if (mapDataAndMarkers && mapDataAndMarkers.Data) { + setMapPins(applyToFetchedPins(mapDataAndMarkers.Data.MapMakerInfos, fetchStartedAt)); + syncPoiLayers(mapDataAndMarkers.Data.PoiLayers ?? []); + + if (!hasCenteredOnDataRef.current) { + hasCenteredOnDataRef.current = true; // Center map on the data center if provided if (mapDataAndMarkers.Data.CenterLat && mapDataAndMarkers.Data.CenterLon && map.current) { @@ -211,58 +240,103 @@ export default function MapWeb() { } } } - } catch (error) { - // Don't log aborted requests as errors - if (error instanceof Error && (error.name === 'AbortError' || error.message === 'canceled')) { - logger.debug({ - message: 'Map data fetch was aborted during component unmount', - }); - return; - } - - logger.error({ - message: 'Failed to fetch initial map data and markers', - context: { error }, + } + } catch (error) { + // Don't log aborted requests as errors + if (error instanceof Error && (error.name === 'AbortError' || error.message === 'canceled')) { + logger.debug({ + message: 'Map data fetch was aborted', }); + return; } + + logger.error({ + message: 'Failed to fetch map data and markers', + context: { error }, + }); + } finally { + if (fetchAbortRef.current === abortController) { + fetchAbortRef.current = null; + } + } + }, [applyToFetchedPins, syncPoiLayers]); + + useEffect(() => { + requestPinsRefreshRef.current = () => { + void fetchMapData(); }; + }, [fetchMapData]); - fetchMapDataAndMarkers(); + // Fetch map data and markers on mount + useEffect(() => { + void fetchMapData(); + // Abort the request if the component unmounts return () => { - abortController.abort(); + fetchAbortRef.current?.abort(); + fetchAbortRef.current = null; }; - }, [syncPoiLayers]); + }, [fetchMapData]); - // Update markers when mapPins change + // Reconcile markers with the visible pins: create new ones, move moved ones in place, rebuild only those whose + // appearance changed, and remove the rest. The camera is never touched here. useEffect(() => { - if (!map.current || !isMapReady) return; + const instance = map.current; + if (!instance || !isMapReady) return; - // Remove existing markers - markersRef.current.forEach((marker) => marker.remove()); - markersRef.current = []; + const theme = colorScheme === 'dark' ? 'dark' : 'light'; + const seenPinIds = new Set(); + + const buildPopupHtml = (pin: MapMakerInfoData) => + `
+

${pin.Title}

+ ${getMapPinSummary(pin) ? `

${getMapPinSummary(pin)}

` : ''} +

+ ${pin.Latitude.toFixed(6)}, ${pin.Longitude.toFixed(6)} +

+
`; - // Add new markers visibleMapPins.forEach((pin) => { if (!hasValidMapCoordinates(pin)) return; + seenPinIds.add(pin.Id); + + const existing = markersRef.current.get(pin.Id); + const meta = markerMetaRef.current.get(pin.Id); + // Pins that did not change keep their object identity, so most markers are skipped outright. + if (existing && meta && meta.pin === pin && meta.signature.startsWith(`${theme}:`)) return; + + const signature = `${theme}:${pin.Title}:${pin.Type}:${pin.PoiTypeId}:${pin.LayerId}:${pin.ImagePath}:${pin.PoiImage}:${pin.Marker}:${pin.Color}:${pin.Address}:${pin.Note}:${pin.PoiTypeName}:${pin.InfoWindowContent}`; + + if (existing && meta && meta.signature === signature) { + if (meta.pin.Latitude !== pin.Latitude || meta.pin.Longitude !== pin.Longitude) { + existing.setLngLat([pin.Longitude, pin.Latitude]); + existing.getPopup()?.setHTML(buildPopupHtml(pin)); + } + markerMetaRef.current.set(pin.Id, { pin, signature }); + return; + } + + existing?.remove(); // Create custom marker element using shared utility - const el = createMapMarkerElement(pin, colorScheme === 'dark' ? 'dark' : 'light'); + const el = createMapMarkerElement(pin, theme); // Create popup - const popup = new mapboxgl.Popup({ offset: 25 }).setHTML( - `
-

${pin.Title}

- ${getMapPinSummary(pin) ? `

${getMapPinSummary(pin)}

` : ''} -

- ${pin.Latitude.toFixed(6)}, ${pin.Longitude.toFixed(6)} -

-
` - ); + const popup = new mapboxgl.Popup({ offset: 25 }).setHTML(buildPopupHtml(pin)); - const marker = new mapboxgl.Marker({ element: el }).setLngLat([pin.Longitude, pin.Latitude]).setPopup(popup).addTo(map.current!); + const marker = new mapboxgl.Marker({ element: el }).setLngLat([pin.Longitude, pin.Latitude]).setPopup(popup).addTo(instance); - markersRef.current.push(marker); + markersRef.current.set(pin.Id, marker); + markerMetaRef.current.set(pin.Id, { pin, signature }); + }); + + // Remove markers whose pins are gone or hidden + markersRef.current.forEach((marker, pinId) => { + if (!seenPinIds.has(pinId)) { + marker.remove(); + markersRef.current.delete(pinId); + markerMetaRef.current.delete(pinId); + } }); }, [visibleMapPins, isMapReady, colorScheme]); diff --git a/src/app/(app)/personnel.tsx b/src/app/(app)/personnel.tsx index 0ba7f08a..5b5523dc 100644 --- a/src/app/(app)/personnel.tsx +++ b/src/app/(app)/personnel.tsx @@ -1,5 +1,4 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { type Href, router } from 'expo-router'; +import { type Href, router, useFocusEffect } from 'expo-router'; import { Circle, Search, User, Users, X } from 'lucide-react-native'; import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/app/(app)/pois.tsx b/src/app/(app)/pois.tsx index 2a429570..72223611 100644 --- a/src/app/(app)/pois.tsx +++ b/src/app/(app)/pois.tsx @@ -1,5 +1,4 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { type Href, router } from 'expo-router'; +import { type Href, router, useFocusEffect } from 'expo-router'; import { ChevronDownIcon, MapPinned, Search, X } from 'lucide-react-native'; import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/app/(app)/scheduled-calls.tsx b/src/app/(app)/scheduled-calls.tsx index 4a92efb7..1b8d5087 100644 --- a/src/app/(app)/scheduled-calls.tsx +++ b/src/app/(app)/scheduled-calls.tsx @@ -1,5 +1,4 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { type Href, router, Stack } from 'expo-router'; +import { type Href, router, Stack, useFocusEffect } from 'expo-router'; import { CalendarClockIcon, Search, X } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index fd3ede32..6ce99531 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -49,6 +49,15 @@ export default function Settings() { await login({ username: data.username, password: data.password }); }; + // The current session's tokens were issued by the previous server, so switching servers + // signs the user out to log in again against the new one. + const handleServerUrlChanged = React.useCallback(async () => { + logger.info({ + message: 'Server URL changed, signing out', + }); + await signOut(); + }, [signOut]); + useEffect(() => { if (status === 'signedIn' && isAuthenticated) { logger.info({ @@ -117,7 +126,7 @@ export default function Settings() { setShowLoginInfo(false)} onSubmit={handleLoginInfoSubmit} /> - setShowServerUrl(false)} /> + setShowServerUrl(false)} onUrlChanged={handleServerUrlChanged} /> ); } diff --git a/src/app/(app)/units.tsx b/src/app/(app)/units.tsx index 58104861..b7948b7f 100644 --- a/src/app/(app)/units.tsx +++ b/src/app/(app)/units.tsx @@ -1,5 +1,4 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { type Href, router } from 'expo-router'; +import { type Href, router, useFocusEffect } from 'expo-router'; import { Circle, MapPin, Search, Truck, X } from 'lucide-react-native'; import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/app/(app)/weather-alerts/index.tsx b/src/app/(app)/weather-alerts/index.tsx index 3679d0e8..e5aa28cd 100644 --- a/src/app/(app)/weather-alerts/index.tsx +++ b/src/app/(app)/weather-alerts/index.tsx @@ -1,5 +1,4 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { type Href, router } from 'expo-router'; +import { type Href, router, useFocusEffect } from 'expo-router'; import { SettingsIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useMemo, useState } from 'react'; diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index d4702556..ab6d2204 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -5,12 +5,11 @@ import '../lib/i18n'; import { Env } from '@env'; import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; import { FloatingDevTools } from '@react-buoy/core'; -import { createNavigationContainerRef, DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import * as Sentry from '@sentry/react-native'; import { isRunningInExpoGo } from 'expo'; import { useFonts } from 'expo-font'; import * as Notifications from 'expo-notifications'; -import { Stack, useNavigationContainerRef } from 'expo-router'; +import { DarkTheme, DefaultTheme, Stack, ThemeProvider, useNavigationContainerRef } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import React, { useEffect } from 'react'; import { LogBox, Platform, useColorScheme } from 'react-native'; @@ -33,7 +32,6 @@ import { uuidv4 } from '@/lib/utils'; import { appInitializationService } from '@/services/app-initialization.service'; export { ErrorBoundary } from 'expo-router'; -export const navigationRef = createNavigationContainerRef(); export const unstable_settings = { initialRouteName: '(app)', diff --git a/src/app/login/index.web.tsx b/src/app/login/index.web.tsx index 62f6dfea..d2e7eacb 100644 --- a/src/app/login/index.web.tsx +++ b/src/app/login/index.web.tsx @@ -9,12 +9,11 @@ import { Pressable, StyleSheet, useWindowDimensions, View } from 'react-native'; import Animated, { FadeIn, FadeInDown, FadeInRight, FadeInUp, FadeOut, FadeOutLeft } from 'react-native-reanimated'; import * as z from 'zod'; -import { getSystemConfig } from '@/api/config'; import { Text } from '@/components/ui/text'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAuth } from '@/lib/auth'; import { logger } from '@/lib/logging'; -import { buildApiUrl, CUSTOM_SERVER_VALUE, toBaseUrl, URL_PATTERN } from '@/lib/server-url'; +import { buildApiUrl, CUSTOM_SERVER_VALUE, findLocationByUrl, loadServerLocations, toBaseUrl, URL_PATTERN } from '@/lib/server-url'; import { type ResgridSystemLocation } from '@/models/v4/configs/getSystemConfigResultData'; import { useServerUrlStore } from '@/stores/app/server-url-store'; @@ -209,15 +208,8 @@ export default function LoginWeb() { // can match it against a hosted site or show it for editing in the custom field. const currentUrl = await getUrl(); const currentBaseUrl = toBaseUrl(currentUrl); - - let fetchedLocations: ResgridSystemLocation[] = []; - try { - const result = await getSystemConfig(); - fetchedLocations = result?.Data?.Locations ?? []; - } catch (err) { - // Best-effort: on failure the user can still enter a custom URL manually. - logger.error({ message: 'Failed to load Resgrid hosted sites', context: { error: err } }); - } + // Always includes the Resgrid hosted sites, even if the current server can't be reached. + const fetchedLocations = await loadServerLocations(); if (isCancelled) { return; @@ -227,7 +219,7 @@ export default function LoginWeb() { // Preselect the hosted site whose API URL matches the persisted URL; otherwise fall // back to the Custom option and show the persisted URL so the user can edit it. - const matchedLocation = fetchedLocations.find((location) => toBaseUrl(location.ApiUrl) === currentBaseUrl); + const matchedLocation = findLocationByUrl(fetchedLocations, currentUrl); if (matchedLocation) { setSelectedServer(matchedLocation.Name); setServerUrlValue('url', toBaseUrl(matchedLocation.ApiUrl)); @@ -546,7 +538,7 @@ export default function LoginWeb() { > {serverLocations.map((location) => ( ))} diff --git a/src/app/personnel/[id].tsx b/src/app/personnel/[id].tsx index 2039d7cf..e940e0cf 100644 --- a/src/app/personnel/[id].tsx +++ b/src/app/personnel/[id].tsx @@ -1,11 +1,13 @@ import { format } from 'date-fns'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { Building2, Circle, Clock, Mail, MapPin, Phone, Shield, User, Users } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { getPersonnelInfo } from '@/api/personnel/personnel'; +import { UdfFieldsRenderer } from '@/components/calls/udf-fields-renderer'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; import { Box } from '@/components/ui/box'; @@ -19,11 +21,15 @@ import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; +// The detail page only displays custom fields; nothing is edited here. +const ignoreUdfValues = () => {}; + export default function PersonnelDetail() { const { id } = useLocalSearchParams(); const userId = Array.isArray(id) ? id[0] : id; const router = useRouter(); const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); const { trackEvent } = useAnalytics(); const [person, setPerson] = useState(null); @@ -321,14 +327,8 @@ export default function PersonnelDetail() { {t('personnel.custom_fields')} - - {person.UdfValues.map((udf, index) => ( - - {udf.UdfFieldId} - {udf.Value || '—'} - - ))} - + {/* Labels and option values come from the definition; the raw rows only carry field ids and stored keys. */} + ) : null} diff --git a/src/app/units/[id].tsx b/src/app/units/[id].tsx index dafa7676..123d4dc4 100644 --- a/src/app/units/[id].tsx +++ b/src/app/units/[id].tsx @@ -1,11 +1,13 @@ import { format } from 'date-fns'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { Building2, Circle, Clock, MapPin, Shield, Truck, User } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView, StyleSheet, View } from 'react-native'; import { getUnitsInfos } from '@/api/units/units'; +import { UdfFieldsRenderer } from '@/components/calls/udf-fields-renderer'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; import { Box } from '@/components/ui/box'; @@ -19,11 +21,15 @@ import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; +// The detail page only displays custom fields; nothing is edited here. +const ignoreUdfValues = () => {}; + export default function UnitDetail() { const { id } = useLocalSearchParams(); const unitId = Array.isArray(id) ? id[0] : id; const router = useRouter(); const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); const { trackEvent } = useAnalytics(); const [unit, setUnit] = useState(null); @@ -289,14 +295,8 @@ export default function UnitDetail() { {t('units.custom_fields')} - - {unit.UdfValues.map((udf, index) => ( - - {udf.UdfFieldId} - {udf.Value || '—'} - - ))} - + {/* Labels and option values come from the definition; the raw rows only carry field ids and stored keys. */} + ) : null} diff --git a/src/components/callVideoFeeds/video-player.tsx b/src/components/callVideoFeeds/video-player.tsx index ed3cb065..f4104144 100644 --- a/src/components/callVideoFeeds/video-player.tsx +++ b/src/components/callVideoFeeds/video-player.tsx @@ -232,7 +232,7 @@ const styles = StyleSheet.create({ flex: 1, }, loadingOverlay: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, alignItems: 'center', justifyContent: 'center', zIndex: 1, diff --git a/src/components/calls/__tests__/call-notes-modal-new.test.tsx b/src/components/calls/__tests__/call-notes-modal-new.test.tsx index efd1c30c..7f2f8b04 100644 --- a/src/components/calls/__tests__/call-notes-modal-new.test.tsx +++ b/src/components/calls/__tests__/call-notes-modal-new.test.tsx @@ -11,7 +11,7 @@ jest.mock('@/lib/auth'); jest.mock('@/stores/calls/detail-store'); // Mock navigation -jest.mock('@react-navigation/native', () => ({ +jest.mock('expo-router', () => ({ useIsFocused: () => true, useNavigation: () => ({ navigate: jest.fn(), diff --git a/src/components/calls/__tests__/udf-fields-renderer.test.tsx b/src/components/calls/__tests__/udf-fields-renderer.test.tsx new file mode 100644 index 00000000..98a4a9e2 --- /dev/null +++ b/src/components/calls/__tests__/udf-fields-renderer.test.tsx @@ -0,0 +1,175 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { getUdfDefinition, getUdfValues } from '@/api/userDefinedFields/userDefinedFields'; +import { type UdfFieldResultData } from '@/models/v4/userDefinedFields/udfFieldResultData'; +import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput'; + +import { UdfFieldsRenderer } from '../udf-fields-renderer'; + +jest.mock('@/api/userDefinedFields/userDefinedFields', () => ({ + getUdfDefinition: jest.fn(), + getUdfValues: jest.fn(), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback ?? key, + }), +})); + +const mockGetUdfDefinition = getUdfDefinition as jest.MockedFunction; +const mockGetUdfValues = getUdfValues as jest.MockedFunction; + +const rules = (options: { Key: string; Label: string }[]) => JSON.stringify({ MinLength: null, Options: options }); + +const makeField = (overrides: Partial): UdfFieldResultData => ({ + UdfFieldId: 'f1', + UdfDefinitionId: 'def-1', + Name: 'field', + Label: 'Field', + Description: '', + Placeholder: '', + FieldDataType: 0, + IsRequired: false, + IsReadOnly: false, + DefaultValue: '', + ValidationRules: '', + SortOrder: 0, + GroupName: '', + IsVisibleOnMobile: true, + IsVisibleOnReports: true, + IsEnabled: true, + Visibility: 0, + ...overrides, +}); + +const outcome = makeField({ + UdfFieldId: 'outcome', + Name: 'outcome', + Label: 'Outcome', + FieldDataType: 11, + ValidationRules: rules([ + { Key: 'tx', Label: 'Transported' }, + { Key: 'refused', Label: 'Refused care' }, + ]), +}); + +const disposition = makeField({ + UdfFieldId: 'disposition', + Name: 'disposition', + Label: 'Disposition', + FieldDataType: 6, + SortOrder: 1, + ValidationRules: rules([ + { Key: 'p1', Label: 'Priority 1' }, + { Key: 'p2', Label: 'Priority 2' }, + ]), +}); + +const setDefinition = (fields: UdfFieldResultData[]) => { + mockGetUdfDefinition.mockResolvedValue({ Data: { UdfDefinitionId: 'def-1', DepartmentId: 1, EntityType: 0, Version: 1, IsActive: true, Fields: fields } } as unknown as Awaited>); +}; + +const setValues = (values: { UdfFieldId: string; Value: string }[]) => { + mockGetUdfValues.mockResolvedValue({ Data: values } as unknown as Awaited>); +}; + +const lastSubmitted = (onValuesChange: jest.Mock): Record => { + const calls = onValuesChange.mock.calls; + const values = calls[calls.length - 1][0] as UdfFieldValueInput[]; + return Object.fromEntries(values.map((v) => [v.UdfFieldId, v.Value])); +}; + +describe('UdfFieldsRenderer', () => { + beforeEach(() => { + jest.clearAllMocks(); + setValues([]); + }); + + it('renders dropdown options by label and stores the key', async () => { + setDefinition([disposition]); + const onValuesChange = jest.fn(); + const { unmount } = render(); + + await waitFor(() => expect(screen.getByText('Priority 2')).toBeTruthy()); + expect(screen.queryByText(/MinLength/)).toBeNull(); + + fireEvent.press(screen.getByText('Priority 2')); + expect(lastSubmitted(onValuesChange).disposition).toBe('p2'); + unmount(); + }); + + it('renders a combo box as a text input with suggestions', async () => { + setDefinition([outcome]); + const { unmount } = render(); + + await waitFor(() => expect(screen.getByTestId('udf-combo-input-outcome')).toBeTruthy()); + expect(screen.getByText('calls.udf_suggestions')).toBeTruthy(); + expect(screen.getByText('Transported')).toBeTruthy(); + expect(screen.getByText('Refused care')).toBeTruthy(); + unmount(); + }); + + it('stores a picked suggestion as its key and shows its label', async () => { + setDefinition([outcome]); + const onValuesChange = jest.fn(); + const { unmount } = render(); + + await waitFor(() => expect(screen.getByText('Refused care')).toBeTruthy()); + fireEvent.press(screen.getByText('Refused care')); + + expect(lastSubmitted(onValuesChange).outcome).toBe('refused'); + expect(screen.getByTestId('udf-combo-input-outcome').props.value).toBe('Refused care'); + unmount(); + }); + + it('sends free text as typed and narrows the suggestions', async () => { + setDefinition([outcome]); + const onValuesChange = jest.fn(); + const { unmount } = render(); + + await waitFor(() => expect(screen.getByTestId('udf-combo-input-outcome')).toBeTruthy()); + fireEvent.changeText(screen.getByTestId('udf-combo-input-outcome'), 'Ref'); + expect(screen.queryByText('Transported')).toBeNull(); + expect(screen.getByText('Refused care')).toBeTruthy(); + + fireEvent.changeText(screen.getByTestId('udf-combo-input-outcome'), 'Referred to crisis line'); + expect(lastSubmitted(onValuesChange).outcome).toBe('Referred to crisis line'); + expect(screen.queryByTestId('udf-combo-suggestions-outcome')).toBeNull(); + unmount(); + }); + + it('shows a stored combo key as its label', async () => { + setDefinition([outcome]); + setValues([{ UdfFieldId: 'outcome', Value: 'tx' }]); + const onValuesChange = jest.fn(); + const { unmount } = render(); + + await waitFor(() => expect(screen.getByTestId('udf-combo-input-outcome').props.value).toBe('Transported')); + expect(lastSubmitted(onValuesChange).outcome).toBe('tx'); + unmount(); + }); + + it('leaves a protected combo value untouched so the server keeps it', async () => { + setDefinition([outcome]); + setValues([{ UdfFieldId: 'outcome', Value: 'REDACTED' }]); + const onValuesChange = jest.fn(); + const { unmount } = render(); + + await waitFor(() => expect(screen.getByTestId('udf-combo-input-outcome').props.value).toBe('REDACTED')); + expect(lastSubmitted(onValuesChange).outcome).toBe('REDACTED'); + unmount(); + }); + + it('shows no suggestions and blocks editing when read-only', async () => { + setDefinition([outcome]); + setValues([{ UdfFieldId: 'outcome', Value: 'refused' }]); + const { unmount } = render(); + + await waitFor(() => expect(screen.getByTestId('udf-combo-input-outcome').props.value).toBe('Refused care')); + expect(screen.getByTestId('udf-combo-input-outcome').props.editable).toBe(false); + expect(screen.queryByTestId('udf-combo-suggestions-outcome')).toBeNull(); + unmount(); + }); +}); diff --git a/src/components/calls/call-audio-modal.tsx b/src/components/calls/call-audio-modal.tsx index 138df63a..a4d4e300 100644 --- a/src/components/calls/call-audio-modal.tsx +++ b/src/components/calls/call-audio-modal.tsx @@ -1,6 +1,6 @@ import type { BottomSheetBackdropProps } from '@gorhom/bottom-sheet'; import BottomSheet, { BottomSheetBackdrop, BottomSheetView } from '@gorhom/bottom-sheet'; -import { Audio, type AVPlaybackStatus } from 'expo-av'; +import { type AudioPlayer, type AudioStatus, createAudioPlayer, setAudioModeAsync } from 'expo-audio'; import { Music, PauseIcon, PlayIcon, X } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -33,7 +33,7 @@ export const CallAudioModal: React.FC = ({ isOpen, onClose, const { callAudio, isLoadingAudio, errorAudio, fetchCallAudio } = useCallDetailStore(); const [playingId, setPlayingId] = useState(null); const [loadingId, setLoadingId] = useState(null); - const soundRef = useRef(null); + const soundRef = useRef(null); // Monotonic id bumped on every play/stop tap so overlapping async loads can tell // whether they are still the latest request before committing state. const playRequestRef = useRef(0); @@ -44,9 +44,9 @@ export const CallAudioModal: React.FC = ({ isOpen, onClose, const unloadSound = useCallback(async () => { if (soundRef.current) { try { - await soundRef.current.unloadAsync(); + soundRef.current.remove(); } catch { - // ignore — sound may already be unloaded + // ignore — player may already be released } soundRef.current = null; } @@ -108,22 +108,25 @@ export const CallAudioModal: React.FC = ({ isOpen, onClose, try { setLoadingId(file.Id); await unloadSound(); - await Audio.setAudioModeAsync({ playsInSilentModeIOS: true, staysActiveInBackground: false }); - const { sound } = await Audio.Sound.createAsync({ uri: file.Url.trim() }, { shouldPlay: true, volume: 1.0 }, (status: AVPlaybackStatus) => { - if (status.isLoaded && status.didJustFinish) { - void unloadSound(); - } - }); + await setAudioModeAsync({ playsInSilentMode: true, shouldPlayInBackground: false }); - // A newer tap arrived while this clip was loading — discard this sound so it - // can't overwrite soundRef.current or start overlapping playback. + // A newer tap arrived while the audio session was being configured — bail out so + // this clip can't overwrite soundRef.current or start overlapping playback. if (requestId !== playRequestRef.current) { - await sound.unloadAsync().catch(() => {}); return; } + const sound = createAudioPlayer({ uri: file.Url.trim() }); + sound.volume = 1.0; + sound.addListener('playbackStatusUpdate', (status: AudioStatus) => { + if (status.didJustFinish && soundRef.current === sound) { + void unloadSound(); + } + }); + soundRef.current = sound; setPlayingId(file.Id); + sound.play(); } catch (error) { logger.error({ message: 'Failed to play call audio', context: { error, callId } }); } finally { diff --git a/src/components/calls/udf-fields-renderer.tsx b/src/components/calls/udf-fields-renderer.tsx index fb339fa5..2055a962 100644 --- a/src/components/calls/udf-fields-renderer.tsx +++ b/src/components/calls/udf-fields-renderer.tsx @@ -1,28 +1,16 @@ -import { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, StyleSheet, TextInput, View } from 'react-native'; +import { ActivityIndicator, type StyleProp, StyleSheet, TextInput, type TextStyle, View } from 'react-native'; import { getUdfDefinition, getUdfValues } from '@/api/userDefinedFields/userDefinedFields'; +import { comboDisplayText, filterComboSuggestions, findComboOption, parseUdfOptions, selectedOptionKeys, UDF_FIELD_TYPE, type UdfOption } from '@/lib/udf/options'; import { type UdfFieldResultData } from '@/models/v4/userDefinedFields/udfFieldResultData'; import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput'; import { type UdfFieldValueResultData } from '@/models/v4/userDefinedFields/udfFieldValueResultData'; import { Text } from '../ui/text'; -// FieldDataType enum -const UDF_FIELD_TYPE = { - Text: 0, - Number: 1, - Decimal: 2, - Boolean: 3, - Date: 4, - DateTime: 5, - Dropdown: 6, - MultiSelect: 7, - Email: 8, - Phone: 9, - Url: 10, -} as const; +const EMPTY_OPTIONS: UdfOption[] = []; interface UdfFieldsRendererProps { entityType: number; @@ -117,6 +105,15 @@ export const UdfFieldsRenderer: React.FC = ({ entityType [fields, notifyChange] ); + // Parsed once per definition so option lists keep their identity across renders. + const optionsByField = useMemo(() => { + const map: Record = {}; + fields.forEach((f) => { + map[f.UdfFieldId] = parseUdfOptions(f.ValidationRules); + }); + return map; + }, [fields]); + if (isLoading) { return ( @@ -154,7 +151,7 @@ export const UdfFieldsRenderer: React.FC = ({ entityType case UDF_FIELD_TYPE.Dropdown: case UDF_FIELD_TYPE.MultiSelect: { - const opts = field.Options && field.Options.length > 0 ? field.Options : field.ValidationRules ? field.ValidationRules.split(',').map((o) => o.trim()) : []; + const opts = optionsByField[field.UdfFieldId] ?? EMPTY_OPTIONS; return ( @@ -175,6 +172,29 @@ export const UdfFieldsRenderer: React.FC = ({ entityType ); } + case UDF_FIELD_TYPE.ComboBox: + return ( + + + {label} + {field.IsRequired ? * : null} + + {field.Description ? {field.Description} : null} + + + ); + case UDF_FIELD_TYPE.Number: case UDF_FIELD_TYPE.Decimal: return ( @@ -339,7 +359,7 @@ const BooleanToggle: React.FC = ({ value, onChange, disabled }; interface OptionSelectorProps { - options: string[]; + options: UdfOption[]; value: string; multiSelect: boolean; onChange: (v: string) => void; @@ -348,37 +368,38 @@ interface OptionSelectorProps { placeholder?: string; } +// Chips show each option's label and store its key, the way the server stores a dropdown/multi-select value. const OptionSelector: React.FC = ({ options, value, multiSelect, onChange, disabled = false, isDark = false }) => { - const selectedValues = value ? value.split(',').map((v) => v.trim()) : []; + const selectedKeys = selectedOptionKeys(multiSelect ? UDF_FIELD_TYPE.MultiSelect : UDF_FIELD_TYPE.Dropdown, value); - const toggle = (opt: string) => { + const toggle = (key: string) => { if (disabled) return; if (multiSelect) { - const next = selectedValues.includes(opt) ? selectedValues.filter((v) => v !== opt) : [...selectedValues, opt]; + const next = selectedKeys.includes(key) ? selectedKeys.filter((k) => k !== key) : [...selectedKeys, key]; onChange(next.join(',')); } else { - onChange(selectedValues.includes(opt) ? '' : opt); + onChange(selectedKeys.includes(key) ? '' : key); } }; return ( {options.map((opt) => { - const isSelected = selectedValues.includes(opt); + const isSelected = selectedKeys.includes(opt.key); return ( toggle(opt)} + onPress={() => toggle(opt.key)} accessibilityRole="button" - accessibilityState={{ selected: isSelected }} + accessibilityState={{ selected: isSelected, disabled }} > - {opt} + {opt.label} ); })} @@ -386,6 +407,95 @@ const OptionSelector: React.FC = ({ options, value, multiSe ); }; +interface ComboBoxInputProps { + fieldId: string; + options: UdfOption[]; + value: string; + onChange: (fieldId: string, value: string) => void; + disabled?: boolean; + isDark?: boolean; + label: string; + placeholder?: string; + inputStyle: StyleProp; + captionStyle: StyleProp; +} + +// A text input with the field's options offered as suggestions. Picking a suggestion shows its label and stores its +// key; anything typed is sent as typed and the server stores a typed label as that option's key. +const ComboBoxInput: React.FC = ({ fieldId, options, value, onChange, disabled = false, isDark = false, label, placeholder, inputStyle, captionStyle }) => { + const { t } = useTranslation(); + // Held locally so typing a string that happens to equal an option key does not flip the input to that label mid-word. + const [text, setText] = useState(() => comboDisplayText(options, value)); + const suggestions = useMemo(() => filterComboSuggestions(options, text), [options, text]); + const selectedKey = findComboOption(options, text)?.key; + + const handleChangeText = useCallback( + (next: string) => { + setText(next); + onChange(fieldId, next); + }, + [fieldId, onChange] + ); + + const handleSelect = useCallback( + (option: UdfOption) => { + if (disabled) return; + setText(option.label); + onChange(fieldId, option.key); + }, + [disabled, fieldId, onChange] + ); + + return ( + + + {!disabled && suggestions.length > 0 ? ( + + {t('calls.udf_suggestions')} + + {suggestions.map((option) => ( + + ))} + + + ) : null} + + ); +}; + +interface ComboSuggestionProps { + option: UdfOption; + isSelected: boolean; + onSelect: (option: UdfOption) => void; + isDark: boolean; +} + +const ComboSuggestion: React.FC = React.memo(({ option, isSelected, onSelect, isDark }) => { + const handlePress = useCallback(() => onSelect(option), [onSelect, option]); + return ( + + {option.label} + + ); +}); + +ComboSuggestion.displayName = 'ComboSuggestion'; + const styles = StyleSheet.create({ container: { width: '100%', @@ -530,4 +640,7 @@ const styles = StyleSheet.create({ optionDisabled: { opacity: 0.5, }, + suggestionsBlock: { + marginTop: 8, + }, }); diff --git a/src/components/contacts/__tests__/contact-details-extra.test.tsx b/src/components/contacts/__tests__/contact-details-extra.test.tsx new file mode 100644 index 00000000..c52d2990 --- /dev/null +++ b/src/components/contacts/__tests__/contact-details-extra.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { type ContactResultData, ContactType } from '@/models/v4/contacts/contactResultData'; + +import { ContactDetailsExtra } from '../contact-details-extra'; + +jest.mock('@/stores/contacts/store', () => ({ + useContactsStore: { + getState: () => ({ + fetchContactNotes: jest.fn().mockResolvedValue(undefined), + fetchContactDetails: jest.fn().mockResolvedValue(undefined), + }), + }, +})); + +jest.mock('@/components/data-protection/protected-reveal-bar', () => ({ + ProtectedRevealBar: () => null, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const makeContact = (customFields: ContactResultData['CustomFields']): ContactResultData => + ({ + ContactId: 'contact-1', + ContactType: ContactType.Person, + Name: 'Jane Doe', + CustomFields: customFields, + }) as ContactResultData; + +describe('ContactDetailsExtra custom fields', () => { + it('shows the display value the server resolved for option fields', () => { + const contact = makeContact([ + { UdfFieldId: 'outcome', Label: 'Outcome', Value: 'tx', DisplayValue: 'Transported', FieldDataType: 11, GroupName: null, SortOrder: 0 }, + { UdfFieldId: 'priority', Label: 'Priority', Value: 'p1', DisplayValue: 'Priority 1', FieldDataType: 6, GroupName: 'Triage', SortOrder: 1 }, + ]); + + const { unmount } = render(); + + expect(screen.getByText('Transported')).toBeTruthy(); + expect(screen.getByText('Priority 1')).toBeTruthy(); + expect(screen.getByText('Triage · Priority')).toBeTruthy(); + expect(screen.queryByText('tx')).toBeNull(); + unmount(); + }); + + it('falls back to the stored value when the server sends no display value', () => { + const contact = makeContact([{ UdfFieldId: 'notes', Label: 'Notes', Value: 'Referred to crisis line', FieldDataType: 11, SortOrder: 0 }]); + + const { unmount } = render(); + + expect(screen.getByText('Referred to crisis line')).toBeTruthy(); + unmount(); + }); + + it('hides custom fields when the host renders its own', () => { + const contact = makeContact([{ UdfFieldId: 'outcome', Label: 'Outcome', Value: 'tx', DisplayValue: 'Transported', FieldDataType: 11, SortOrder: 0 }]); + + const { unmount } = render(); + + expect(screen.queryByTestId('contact-custom-fields')).toBeNull(); + unmount(); + }); +}); diff --git a/src/components/contacts/contact-details-extra.tsx b/src/components/contacts/contact-details-extra.tsx index 1b897d50..af6f1db0 100644 --- a/src/components/contacts/contact-details-extra.tsx +++ b/src/components/contacts/contact-details-extra.tsx @@ -110,7 +110,7 @@ export const ContactDetailsExtra: React.FC = ({ contac {customFields.map((field) => ( {field.GroupName ? `${field.GroupName} · ${field.Label}` : field.Label} - {field.Value} + {field.DisplayValue || field.Value} ))}
diff --git a/src/components/maps/__tests__/pin-actions.test.tsx b/src/components/maps/__tests__/pin-actions.test.tsx index af932f22..921f7df7 100644 --- a/src/components/maps/__tests__/pin-actions.test.tsx +++ b/src/components/maps/__tests__/pin-actions.test.tsx @@ -144,8 +144,9 @@ jest.mock('@/stores/toast/store', () => ({ }), })); +// Pin ids and types as the Core map API sends them (calls are Type 0 with a `c` prefix). const mockCallPin = { - Id: '123', + Id: 'c123', Title: 'Medical Emergency', Latitude: 40.7128, Longitude: -74.0060, @@ -165,12 +166,12 @@ const mockCallPin = { }; const mockUnitPin = { - Id: '456', + Id: 'u456', Title: 'Engine 1', Latitude: 40.7580, Longitude: -73.9855, ImagePath: 'engine_available', - Type: 2, + Type: 1, InfoWindowContent: 'Engine 1 available', Color: '#00ff00', zIndex: 1, @@ -382,6 +383,29 @@ describe('Pin Actions Integration Tests', () => { expect(mockOnClose).toHaveBeenCalledTimes(1); }); + it('should navigate with a legacy unprefixed call pin id unchanged', () => { + render(); + + fireEvent.press(screen.getByText('map.view_call_details')); + + expect(mockRouter.push).toHaveBeenCalledWith('/call/123'); + }); + + it('should open POI details with the bare POI id', () => { + render( + + ); + + fireEvent.press(screen.getByText('map.view_poi_details')); + + expect(mockRouter.push).toHaveBeenCalledWith('/poi/9'); + }); + it('should not show call details button for non-call pins', () => { render( void>; + flyTo: jest.Mock; +} +const mockMarkers: MockMarker[] = []; +const mockMaps: MockMap[] = []; + +jest.mock('mapbox-gl', () => { + class Popup { + setHTML = jest.fn(() => this); + } + class Marker { + lngLat: [number, number] = [0, 0]; + popup: Popup | undefined; + setLngLat = jest.fn((lngLat: [number, number]) => { + this.lngLat = lngLat; + return this; + }); + remove = jest.fn(); + constructor() { + mockMarkers.push(this as unknown as MockMarker); + } + setPopup(popup: Popup) { + this.popup = popup; + return this; + } + getPopup() { + return this.popup; + } + addTo() { + return this; + } + } + class MapboxMap { + handlers: Record void> = {}; + flyTo = jest.fn(); + constructor() { + mockMaps.push(this as unknown as MockMap); + } + addControl() {} + on(event: string, handler: () => void) { + this.handlers[event] = handler; + } + once() {} + off() {} + remove() {} + setStyle() {} + getLayer() { + return undefined; + } + getSource() { + return undefined; + } + addSource() {} + addLayer() {} + } + class Control {} + return { __esModule: true, default: { Map: MapboxMap, Marker, Popup, NavigationControl: Control, GeolocateControl: Control, accessToken: '' } }; +}); + +jest.mock('@/stores/signalr/signalr-store', () => { + const { create } = jest.requireActual('zustand'); + return { useSignalRStore: create(() => ({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 })) }; +}); + +jest.mock('@/api/mapping/mapping', () => ({ getMapDataAndMarkers: jest.fn() })); +jest.mock('@/lib/map-markers-web', () => ({ createMapMarkerElement: jest.fn(() => ({})) })); +jest.mock('@/lib/map-center', () => ({ getDepartmentMapCenter: () => ({ latitude: 39, longitude: -119, zoomLevel: 9 }) })); +jest.mock('@/lib/env', () => ({ Env: { MAPBOX_PUBKEY: 'pk.test' } })); +jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() } })); +jest.mock('@/stores/app/location-store', () => ({ useLocationStore: { getState: () => ({ latitude: null, longitude: null }) } })); +jest.mock('nativewind', () => ({ useColorScheme: () => ({ colorScheme: 'light' }) })); + +const mockGetMapDataAndMarkers = getMapDataAndMarkers as jest.MockedFunction; + +const makePin = (id: string, type: number, latitude: number, longitude: number) => ({ Id: id, Type: type, Latitude: latitude, Longitude: longitude, Title: id }) as MapMakerInfoData; + +const mapResponse = (pins: MapMakerInfoData[]) => ({ Data: { MapMakerInfos: pins, CenterLat: '39.5', CenterLon: '-119.8', ZoomLevel: '11', PoiLayers: [] } }) as any; + +const REST_PINS = [makePin('c1', 0, 39.1, -119.1), makePin('u12', 1, 39.2, -119.2), makePin('pABC', 3, 39.3, -119.3)]; + +describe('UnifiedMapView (web) realtime locations', () => { + const originalDocument = (global as any).document; + + beforeEach(() => { + jest.useFakeTimers(); + mockMarkers.length = 0; + mockMaps.length = 0; + mockGetMapDataAndMarkers.mockReset(); + mockGetMapDataAndMarkers.mockResolvedValue(mapResponse(REST_PINS)); + useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 }); + // Only the stylesheet injection touches document; report it as already present. + (global as any).document = { getElementById: () => ({}) }; + }); + + afterEach(() => { + (global as any).document = originalDocument; + jest.useRealTimers(); + }); + + const renderLoadedMap = async () => { + const view = render(, { createNodeMock: () => ({}) }); + act(() => mockMaps[0].handlers.load()); + await waitFor(() => expect(mockMarkers).toHaveLength(REST_PINS.length)); + return view; + }; + + const markerAt = (latitude: number, longitude: number) => mockMarkers.find((marker) => marker.lngLat[0] === longitude && marker.lngLat[1] === latitude); + + it('moves the pushed pin in place without rebuilding markers or moving the camera', async () => { + await renderLoadedMap(); + const unitMarker = markerAt(39.2, -119.2)!; + expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1); + + act(() => { + useSignalRStore.setState({ liveLocations: { u12: { pinId: 'u12', latitude: 40, longitude: -120, timestamp: null, receivedAt: Date.now() } } }); + }); + + expect(unitMarker.setLngLat).toHaveBeenLastCalledWith([-120, 40]); + expect(mockMarkers).toHaveLength(REST_PINS.length); + mockMarkers.forEach((marker) => expect(marker.remove).not.toHaveBeenCalled()); + expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1); + }); + + it('refetches in the background for a pin it does not have, without moving the camera', async () => { + await renderLoadedMap(); + mockGetMapDataAndMarkers.mockResolvedValue(mapResponse([...REST_PINS, makePin('u99', 1, 38, -118)])); + + act(() => { + useSignalRStore.setState({ liveLocations: { u99: { pinId: 'u99', latitude: 38.5, longitude: -118.5, timestamp: null, receivedAt: Date.now() } } }); + }); + expect(mockMarkers).toHaveLength(REST_PINS.length); + + await act(async () => { + jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS); + }); + + await waitFor(() => expect(mockMarkers).toHaveLength(REST_PINS.length + 1)); + expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(2); + // The push arrived before the refetch started, so the fresh REST position stands. + expect(markerAt(38, -118)).toBeDefined(); + expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/maps/pin-detail-modal.tsx b/src/components/maps/pin-detail-modal.tsx index fbf0b2ab..0ad11e60 100644 --- a/src/components/maps/pin-detail-modal.tsx +++ b/src/components/maps/pin-detail-modal.tsx @@ -15,6 +15,7 @@ import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { isCallMarker, isPoiMarker } from '@/lib/destination-helpers'; import { getMapPinSummary, hasValidMapCoordinates } from '@/lib/map-markers'; +import { getPinEntityId } from '@/lib/map-pin-ids'; import { openMapsWithDirections } from '@/lib/navigation'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { useLocationStore } from '@/stores/app/location-store'; @@ -67,15 +68,19 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC }; const handleViewCallDetails = () => { - if (isCallPin && pin.Id) { - router.push(`/call/${pin.Id}` as Href); + const callId = getPinEntityId(pin); + + if (isCallPin && callId) { + router.push(`/call/${callId}` as Href); onClose(); } }; const handleViewPoiDetails = () => { - if (isPoiPin && pin.Id) { - router.push(`/poi/${pin.Id}` as Href); + const poiId = getPinEntityId(pin); + + if (isPoiPin && poiId) { + router.push(`/poi/${poiId}` as Href); onClose(); } }; diff --git a/src/components/maps/unified-map-view.tsx b/src/components/maps/unified-map-view.tsx index b1f37c07..cd2fb6dd 100644 --- a/src/components/maps/unified-map-view.tsx +++ b/src/components/maps/unified-map-view.tsx @@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { StyleSheet, View } from 'react-native'; import { getMapDataAndMarkers } from '@/api/mapping/mapping'; +import { useMapLiveLocations } from '@/hooks/use-map-live-locations'; import { logger } from '@/lib/logging'; import { getDepartmentMapCenter } from '@/lib/map-center'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; @@ -66,6 +67,12 @@ const UnifiedMapViewComponent: React.FC = ({ // Use external pins if provided, otherwise use internal pins const mapPins = externalPins ?? internalPins; + // Realtime unit/personnel positions from the geolocation hub move the pins this view fetched itself; a parent + // that passes `pins` owns them. Refetch requests reuse refreshPins below. + const requestPinsRefreshRef = useRef<(() => void) | null>(null); + const requestPinsRefresh = useCallback(() => requestPinsRefreshRef.current?.(), []); + const { applyToFetchedPins } = useMapLiveLocations({ pins: internalPins, setPins: setInternalPins, requestRefresh: requestPinsRefresh, enabled: autoFetchPins && externalPins === undefined }); + // Get map style based on current theme const getMapStyle = useCallback(() => { return colorScheme === 'dark' ? Mapbox.StyleURL.Dark : Mapbox.StyleURL.Street; @@ -101,11 +108,12 @@ const UnifiedMapViewComponent: React.FC = ({ const fetchMapDataAndMarkers = async () => { try { + const fetchStartedAt = Date.now(); const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal); if (mapDataAndMarkers?.Data) { const markers = mapDataAndMarkers.Data.MapMakerInfos; - setInternalPins(markers); + setInternalPins(applyToFetchedPins(markers, fetchStartedAt)); // Center map on the data center if provided if (mapDataAndMarkers.Data.CenterLat && mapDataAndMarkers.Data.CenterLon && cameraRef.current) { @@ -150,7 +158,45 @@ const UnifiedMapViewComponent: React.FC = ({ return () => { abortController.abort(); }; - }, [autoFetchPins]); + }, [autoFetchPins, applyToFetchedPins]); + + // Background refetch of the pins (update-hub changes, realtime location requests). Never moves the camera. + const refreshPins = useCallback(async () => { + // Abort any in-flight refresh + if (signalRAbortController.current) { + signalRAbortController.current.abort(); + } + const controller = new AbortController(); + signalRAbortController.current = controller; + + try { + logger.debug({ + message: 'Refreshing map pins in the background', + }); + + const fetchStartedAt = Date.now(); + const mapDataAndMarkers = await getMapDataAndMarkers(controller.signal); + if (!controller.signal.aborted && mapDataAndMarkers?.Data) { + setInternalPins(applyToFetchedPins(mapDataAndMarkers.Data.MapMakerInfos, fetchStartedAt)); + } + } catch (error) { + if (error instanceof Error && (error.name === 'AbortError' || error.message === 'canceled')) { + return; + } + logger.error({ + message: 'Failed to refresh map pins', + context: { error }, + }); + } + }, [applyToFetchedPins]); + + useEffect(() => { + requestPinsRefreshRef.current = autoFetchPins + ? () => { + void refreshPins(); + } + : null; + }, [autoFetchPins, refreshPins]); // Refresh pins when SignalR updates come in (only when autoFetchPins is enabled) useEffect(() => { @@ -162,33 +208,8 @@ const UnifiedMapViewComponent: React.FC = ({ } // Debounce to prevent rapid consecutive API calls from multiple SignalR events - signalRDebounceTimer.current = setTimeout(async () => { - // Abort any in-flight SignalR-triggered fetch - if (signalRAbortController.current) { - signalRAbortController.current.abort(); - } - const controller = new AbortController(); - signalRAbortController.current = controller; - - try { - logger.debug({ - message: 'Refreshing map pins from SignalR update', - context: { timestamp: lastUpdateTimestamp }, - }); - - const mapDataAndMarkers = await getMapDataAndMarkers(controller.signal); - if (!controller.signal.aborted && mapDataAndMarkers?.Data) { - setInternalPins(mapDataAndMarkers.Data.MapMakerInfos); - } - } catch (error) { - if (error instanceof Error && (error.name === 'AbortError' || error.message === 'canceled')) { - return; - } - logger.error({ - message: 'Failed to refresh map pins from SignalR update', - context: { error }, - }); - } + signalRDebounceTimer.current = setTimeout(() => { + void refreshPins(); }, 1500); return () => { @@ -199,7 +220,14 @@ const UnifiedMapViewComponent: React.FC = ({ signalRAbortController.current.abort(); } }; - }, [autoFetchPins, lastUpdateTimestamp]); + }, [autoFetchPins, lastUpdateTimestamp, refreshPins]); + + // Abort a background refresh still in flight when the view unmounts. + useEffect(() => { + return () => { + signalRAbortController.current?.abort(); + }; + }, []); // Helper function to get layer style based on type const getLayerStyle = (layer: GetMapLayersData): FillLayerStyle | LineLayerStyle | CircleLayerStyle => { diff --git a/src/components/maps/unified-map-view.web.tsx b/src/components/maps/unified-map-view.web.tsx index 53de5479..1f9021c1 100644 --- a/src/components/maps/unified-map-view.web.tsx +++ b/src/components/maps/unified-map-view.web.tsx @@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { StyleSheet, View } from 'react-native'; import { getMapDataAndMarkers } from '@/api/mapping/mapping'; +import { useMapLiveLocations } from '@/hooks/use-map-live-locations'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; import { getDepartmentMapCenter } from '@/lib/map-center'; @@ -47,6 +48,20 @@ interface UnifiedMapViewProps { testID?: string; } +// Helper function to calculate center from markers +const calculateCenterFromMarkers = (markers: MapMakerInfoData[]): { lat: number; lon: number } | null => { + const validMarkers = markers.filter((m) => m.Latitude && m.Longitude); + if (validMarkers.length === 0) return null; + + const sumLat = validMarkers.reduce((sum, m) => sum + m.Latitude, 0); + const sumLon = validMarkers.reduce((sum, m) => sum + m.Longitude, 0); + + return { + lat: sumLat / validMarkers.length, + lon: sumLon / validMarkers.length, + }; +}; + /** * Unified Map View component for Web using mapbox-gl-js. * Supports pins, layers, and user location. @@ -66,7 +81,7 @@ const UnifiedMapViewComponent: React.FC = ({ const mapContainer = useRef(null); const map = useRef(null); const markersRef = useRef>(new Map()); - const markerMetaRef = useRef>(new Map()); + const markerMetaRef = useRef>(new Map()); const onPinPressRef = useRef(onPinPress); const layerIdsRef = useRef([]); const sourceIdsRef = useRef([]); @@ -77,6 +92,12 @@ const UnifiedMapViewComponent: React.FC = ({ // Use external pins if provided, otherwise use internal pins const mapPins = externalPins ?? internalPins; + // Realtime unit/personnel positions from the geolocation hub move the pins this view fetched itself; a parent + // that passes `pins` owns them. Refetch requests reuse fetchPins below. + const requestPinsRefreshRef = useRef<(() => void) | null>(null); + const requestPinsRefresh = useCallback(() => requestPinsRefreshRef.current?.(), []); + const { applyToFetchedPins } = useMapLiveLocations({ pins: internalPins, setPins: setInternalPins, requestRefresh: requestPinsRefresh, enabled: autoFetchPins && externalPins === undefined }); + // Get map style based on current theme const getMapStyle = useCallback(() => { return colorScheme === 'dark' ? 'mapbox://styles/mapbox/dark-v11' : 'mapbox://styles/mapbox/streets-v12'; @@ -152,33 +173,29 @@ const UnifiedMapViewComponent: React.FC = ({ } }, [colorScheme, getMapStyle, isMapReady]); - // Helper function to calculate center from markers - const calculateCenterFromMarkers = (markers: MapMakerInfoData[]): { lat: number; lon: number } | null => { - const validMarkers = markers.filter((m) => m.Latitude && m.Longitude); - if (validMarkers.length === 0) return null; - - const sumLat = validMarkers.reduce((sum, m) => sum + m.Latitude, 0); - const sumLon = validMarkers.reduce((sum, m) => sum + m.Longitude, 0); - - return { - lat: sumLat / validMarkers.length, - lon: sumLon / validMarkers.length, - }; - }; + // Fetches the pins this view shows when auto-fetching: once on mount, and again in the background when the + // realtime location feed asks for it. Only the first successful load may move the camera. + const fetchAbortRef = useRef(null); + const hasCenteredOnDataRef = useRef(false); + const fetchPins = useCallback(async () => { + fetchAbortRef.current?.abort(); + const abortController = new AbortController(); + fetchAbortRef.current = abortController; - // Auto-fetch pins if enabled - useEffect(() => { - if (!autoFetchPins) return; + try { + const fetchStartedAt = Date.now(); + const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal); - const abortController = new AbortController(); + if (abortController.signal.aborted) { + return; + } - const fetchMapDataAndMarkers = async () => { - try { - const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal); + if (mapDataAndMarkers?.Data) { + const markers = mapDataAndMarkers.Data.MapMakerInfos; + setInternalPins(applyToFetchedPins(markers, fetchStartedAt)); - if (mapDataAndMarkers?.Data) { - const markers = mapDataAndMarkers.Data.MapMakerInfos; - setInternalPins(markers); + if (!hasCenteredOnDataRef.current) { + hasCenteredOnDataRef.current = true; // Center map on the data center if provided if (mapDataAndMarkers.Data.CenterLat && mapDataAndMarkers.Data.CenterLon && map.current) { @@ -206,24 +223,42 @@ const UnifiedMapViewComponent: React.FC = ({ } } } - } catch (error) { - if (error instanceof Error && (error.name === 'AbortError' || error.message === 'canceled')) { - return; - } + } + } catch (error) { + if (error instanceof Error && (error.name === 'AbortError' || error.message === 'canceled')) { + return; + } - logger.error({ - message: 'Failed to fetch map data', - context: { error }, - }); + logger.error({ + message: 'Failed to fetch map data', + context: { error }, + }); + } finally { + if (fetchAbortRef.current === abortController) { + fetchAbortRef.current = null; } - }; + } + }, [applyToFetchedPins]); + + useEffect(() => { + requestPinsRefreshRef.current = autoFetchPins + ? () => { + void fetchPins(); + } + : null; + }, [autoFetchPins, fetchPins]); - fetchMapDataAndMarkers(); + // Auto-fetch pins if enabled + useEffect(() => { + if (!autoFetchPins) return; + + void fetchPins(); return () => { - abortController.abort(); + fetchAbortRef.current?.abort(); + fetchAbortRef.current = null; }; - }, [autoFetchPins]); + }, [autoFetchPins, fetchPins]); // Keep a ref to the latest onPinPress so marker click handlers always call the // current callback without forcing a marker rebuild when its identity changes. @@ -251,11 +286,16 @@ const UnifiedMapViewComponent: React.FC = ({ if (!hasValidMapCoordinates(pin)) return; seenPinIds.add(pin.Id); - const signature = `${theme}:${pin.Title}:${pin.Type}:${pin.PoiTypeId}:${pin.LayerId}:${pin.ImagePath}:${pin.PoiImage}:${pin.Marker}:${pin.Color}:${pin.Address}:${pin.Note}:${pin.PoiTypeName}:${pin.InfoWindowContent}`; const existing = markersRef.current.get(pin.Id); + const currentMeta = markerMetaRef.current.get(pin.Id); + // Live location moves replace only the moved pins' objects, so an unchanged pin is skipped outright + // instead of re-deriving its signature on every realtime update. + if (existing && currentMeta && currentMeta.pin === pin && currentMeta.signature.startsWith(`${theme}:`)) return; + + const signature = `${theme}:${pin.Title}:${pin.Type}:${pin.PoiTypeId}:${pin.LayerId}:${pin.ImagePath}:${pin.PoiImage}:${pin.Marker}:${pin.Color}:${pin.Address}:${pin.Note}:${pin.PoiTypeName}:${pin.InfoWindowContent}`; if (existing) { - const meta = markerMetaRef.current.get(pin.Id); + const meta = currentMeta; const moved = !meta || meta.latitude !== pin.Latitude || meta.longitude !== pin.Longitude; if (moved) { @@ -265,8 +305,8 @@ const UnifiedMapViewComponent: React.FC = ({ if (meta && meta.signature === signature) { if (moved) { existing.getPopup()?.setHTML(buildPopupHtml(pin)); - markerMetaRef.current.set(pin.Id, { signature, latitude: pin.Latitude, longitude: pin.Longitude }); } + markerMetaRef.current.set(pin.Id, { signature, latitude: pin.Latitude, longitude: pin.Longitude, pin }); return; } @@ -274,9 +314,11 @@ const UnifiedMapViewComponent: React.FC = ({ markersRef.current.delete(pin.Id); } - // Create custom marker element using shared utility + // Create custom marker element using shared utility. The click handler looks the pin up by id so a + // marker that has since moved reports its current data, not the pin it was created from. + const pinId = pin.Id; const el = createMapMarkerElement(pin, theme, () => { - onPinPressRef.current?.(pin); + onPinPressRef.current?.(markerMetaRef.current.get(pinId)?.pin ?? pin); }); // Create popup @@ -285,7 +327,7 @@ const UnifiedMapViewComponent: React.FC = ({ const marker = new mapboxgl.Marker({ element: el }).setLngLat([pin.Longitude, pin.Latitude]).setPopup(popup).addTo(map.current!); markersRef.current.set(pin.Id, marker); - markerMetaRef.current.set(pin.Id, { signature, latitude: pin.Latitude, longitude: pin.Longitude }); + markerMetaRef.current.set(pin.Id, { signature, latitude: pin.Latitude, longitude: pin.Longitude, pin }); }); // Remove stale markers diff --git a/src/components/settings/__tests__/login-info-bottom-sheet-simple.test.tsx b/src/components/settings/__tests__/login-info-bottom-sheet-simple.test.tsx index 4035edc8..fe49c999 100644 --- a/src/components/settings/__tests__/login-info-bottom-sheet-simple.test.tsx +++ b/src/components/settings/__tests__/login-info-bottom-sheet-simple.test.tsx @@ -215,7 +215,7 @@ describe('LoginInfoBottomSheet', () => { it('calls onClose when cancel button is pressed', () => { render(); - const cancelButton = screen.getByText('common.cancel').parent; + const cancelButton = screen.getByText('common.cancel').parent!; fireEvent.press(cancelButton); expect(mockOnClose).toHaveBeenCalled(); diff --git a/src/components/settings/__tests__/login-info-bottom-sheet.test.tsx b/src/components/settings/__tests__/login-info-bottom-sheet.test.tsx index 4035edc8..fe49c999 100644 --- a/src/components/settings/__tests__/login-info-bottom-sheet.test.tsx +++ b/src/components/settings/__tests__/login-info-bottom-sheet.test.tsx @@ -215,7 +215,7 @@ describe('LoginInfoBottomSheet', () => { it('calls onClose when cancel button is pressed', () => { render(); - const cancelButton = screen.getByText('common.cancel').parent; + const cancelButton = screen.getByText('common.cancel').parent!; fireEvent.press(cancelButton); expect(mockOnClose).toHaveBeenCalled(); diff --git a/src/components/settings/__tests__/server-url-bottom-sheet-simple.test.tsx b/src/components/settings/__tests__/server-url-bottom-sheet-simple.test.tsx index 514176db..47217758 100644 --- a/src/components/settings/__tests__/server-url-bottom-sheet-simple.test.tsx +++ b/src/components/settings/__tests__/server-url-bottom-sheet-simple.test.tsx @@ -29,6 +29,8 @@ jest.mock('react-hook-form', () => ({ control: {}, handleSubmit: jest.fn(), setValue: jest.fn(), + setError: jest.fn(), + clearErrors: jest.fn(), formState: { errors: {} }, }), Controller: ({ render }: any) => render({ field: { onChange: jest.fn(), value: '' } }), @@ -42,7 +44,7 @@ jest.mock('@/stores/app/server-url-store', () => ({ })); jest.mock('@/lib/env', () => ({ Env: { API_VERSION: 'v4' } })); -jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), error: jest.fn() } })); +jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() } })); jest.mock('@/api/config', () => ({ getSystemConfig: jest.fn().mockResolvedValue({ Data: { Locations: [] } }), })); diff --git a/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx b/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx new file mode 100644 index 00000000..fc477bfe --- /dev/null +++ b/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx @@ -0,0 +1,217 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { ServerUrlBottomSheet } from '../server-url-bottom-sheet'; + +const mockGetSystemConfig = jest.fn(); +const mockGetUrl = jest.fn(); +const mockSetUrl = jest.fn(); +let mockOnValueChange: ((value: string) => void) | undefined; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +jest.mock('nativewind', () => ({ + styled: jest.fn((Component: any) => Component), + useColorScheme: () => ({ colorScheme: 'light' }), +})); +jest.mock('@/api/config', () => ({ + getSystemConfig: () => mockGetSystemConfig(), +})); +jest.mock('@/stores/app/server-url-store', () => ({ + useServerUrlStore: () => ({ getUrl: mockGetUrl, setUrl: mockSetUrl }), +})); +jest.mock('@/lib/env', () => ({ Env: { API_VERSION: 'v4' } })); +jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() } })); +jest.mock('lucide-react-native', () => ({ ChevronDownIcon: 'ChevronDownIcon' })); + +jest.mock('../../ui/actionsheet', () => { + const React = require('react'); + const { View } = require('react-native'); + const Passthrough = ({ children }: any) => React.createElement(View, {}, children); + return { + Actionsheet: ({ children, isOpen }: any) => (isOpen ? React.createElement(View, { testID: 'actionsheet' }, children) : null), + ActionsheetBackdrop: Passthrough, + ActionsheetContent: Passthrough, + ActionsheetDragIndicator: Passthrough, + ActionsheetDragIndicatorWrapper: Passthrough, + }; +}); +jest.mock('../../ui/button', () => { + const React = require('react'); + const { Text, TouchableOpacity, View } = require('react-native'); + return { + Button: ({ children, onPress, disabled }: any) => React.createElement(TouchableOpacity, { onPress, disabled }, children), + ButtonText: ({ children }: any) => React.createElement(Text, {}, children), + ButtonSpinner: () => React.createElement(View, { testID: 'button-spinner' }), + }; +}); +jest.mock('../../ui/form-control', () => { + const React = require('react'); + const { Text, View } = require('react-native'); + const Passthrough = ({ children }: any) => React.createElement(View, {}, children); + return { + FormControl: Passthrough, + FormControlLabel: Passthrough, + FormControlLabelText: ({ children }: any) => React.createElement(Text, {}, children), + FormControlHelperText: Passthrough, + FormControlError: Passthrough, + FormControlErrorText: ({ children }: any) => React.createElement(Text, {}, children), + }; +}); +jest.mock('../../ui/select', () => { + const React = require('react'); + const { Text, View } = require('react-native'); + const Passthrough = ({ children }: any) => React.createElement(View, {}, children); + return { + Select: ({ children, onValueChange }: any) => { + mockOnValueChange = onValueChange; + return React.createElement(View, {}, children); + }, + SelectBackdrop: Passthrough, + SelectContent: Passthrough, + SelectDragIndicator: Passthrough, + SelectDragIndicatorWrapper: Passthrough, + SelectIcon: Passthrough, + SelectInput: ({ value }: any) => React.createElement(Text, { testID: 'select-input' }, value), + SelectItem: ({ label, value }: any) => React.createElement(Text, { testID: `select-item-${value}` }, label), + SelectPortal: Passthrough, + SelectTrigger: Passthrough, + }; +}); +jest.mock('../../ui/input', () => { + const React = require('react'); + const { TextInput, View } = require('react-native'); + return { + Input: ({ children }: any) => React.createElement(View, {}, children), + InputField: (props: any) => React.createElement(TextInput, { testID: 'server-url-input', ...props }), + }; +}); +jest.mock('../../ui/center', () => { + const React = require('react'); + const { View } = require('react-native'); + return { Center: ({ children }: any) => React.createElement(View, {}, children) }; +}); +jest.mock('../../ui/hstack', () => { + const React = require('react'); + const { View } = require('react-native'); + return { HStack: ({ children }: any) => React.createElement(View, {}, children) }; +}); +jest.mock('../../ui/vstack', () => { + const React = require('react'); + const { View } = require('react-native'); + return { VStack: ({ children }: any) => React.createElement(View, {}, children) }; +}); +jest.mock('../../ui/text', () => { + const React = require('react'); + const { Text } = require('react-native'); + return { Text: ({ children }: any) => React.createElement(Text, {}, children) }; +}); +jest.mock('../../ui/spinner', () => { + const React = require('react'); + const { View } = require('react-native'); + return { Spinner: () => React.createElement(View, { testID: 'spinner' }) }; +}); + +const hostedConfig = { + Data: { + Locations: [ + { Name: 'US-West', DisplayName: 'Resgrid North America (Global)', LocationInfo: '', IsDefault: true, ApiUrl: 'https://api.resgrid.com', AllowsFreeAccounts: true }, + { Name: 'EU-Central', DisplayName: 'Resgrid Europe', LocationInfo: '', IsDefault: false, ApiUrl: 'https://api-eu-central.resgrid.com', AllowsFreeAccounts: false }, + ], + }, +}; + +describe('ServerUrlBottomSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockOnValueChange = undefined; + mockGetSystemConfig.mockResolvedValue(hostedConfig); + mockGetUrl.mockResolvedValue('https://api.resgrid.com/api/v4'); + mockSetUrl.mockResolvedValue(undefined); + }); + + const renderSheet = (props: Partial> = {}) => render(); + + it('lists the US-West and EU-Central sites plus a Custom option', async () => { + renderSheet(); + + expect(await screen.findByTestId('select-item-US-West')).toHaveTextContent('US-West'); + expect(screen.getByTestId('select-item-EU-Central')).toHaveTextContent('EU-Central'); + expect(screen.getByTestId('select-item-__custom__')).toHaveTextContent('settings.custom'); + }); + + it('still lists the hosted sites when the configured server is unreachable', async () => { + mockGetSystemConfig.mockRejectedValue(new Error('Network Error')); + mockGetUrl.mockResolvedValue('https://offline.example.org/api/v4'); + + renderSheet(); + + expect(await screen.findByTestId('select-item-US-West')).toBeTruthy(); + expect(screen.getByTestId('select-item-EU-Central')).toBeTruthy(); + expect(screen.getByTestId('select-input')).toHaveTextContent('settings.custom'); + expect(screen.getByTestId('server-url-input').props.value).toBe('https://offline.example.org'); + }); + + it('preselects the hosted site matching the saved url', async () => { + mockGetUrl.mockResolvedValue('https://api-eu-central.resgrid.com/api/v4'); + + renderSheet(); + + await waitFor(() => expect(screen.getByTestId('select-input')).toHaveTextContent('EU-Central')); + expect(screen.getByTestId('server-url-input').props.value).toBe('https://api-eu-central.resgrid.com'); + expect(screen.getByTestId('server-url-input').props.editable).toBe(false); + }); + + it('saves the selected hosted site and reports the server change', async () => { + const onClose = jest.fn(); + const onUrlChanged = jest.fn().mockResolvedValue(undefined); + renderSheet({ onClose, onUrlChanged }); + await screen.findByTestId('select-item-EU-Central'); + + act(() => mockOnValueChange?.('EU-Central')); + fireEvent.press(screen.getByText('common.save')); + + await waitFor(() => expect(mockSetUrl).toHaveBeenCalledWith('https://api-eu-central.resgrid.com/api/v4')); + await waitFor(() => expect(onUrlChanged).toHaveBeenCalledTimes(1)); + expect(onClose).toHaveBeenCalled(); + }); + + it('does not report a change when the same server is saved again', async () => { + const onClose = jest.fn(); + const onUrlChanged = jest.fn(); + renderSheet({ onClose, onUrlChanged }); + await waitFor(() => expect(screen.getByTestId('select-input')).toHaveTextContent('US-West')); + + fireEvent.press(screen.getByText('common.save')); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(mockSetUrl).toHaveBeenCalledWith('https://api.resgrid.com/api/v4'); + expect(onUrlChanged).not.toHaveBeenCalled(); + }); + + it('saves a custom url with the api suffix', async () => { + const onClose = jest.fn(); + renderSheet({ onClose }); + await screen.findByTestId('select-item-__custom__'); + + act(() => mockOnValueChange?.('__custom__')); + fireEvent.changeText(await screen.findByTestId('server-url-input'), 'https://resgrid.example.org/'); + fireEvent.press(screen.getByText('common.save')); + + await waitFor(() => expect(mockSetUrl).toHaveBeenCalledWith('https://resgrid.example.org/api/v4')); + expect(onClose).toHaveBeenCalled(); + }); + + it('rejects a non-https custom url', async () => { + renderSheet(); + await screen.findByTestId('select-item-__custom__'); + + act(() => mockOnValueChange?.('__custom__')); + fireEvent.changeText(await screen.findByTestId('server-url-input'), 'http://resgrid.example.org'); + fireEvent.press(screen.getByText('common.save')); + + expect(await screen.findByText('form.invalid_url')).toBeTruthy(); + expect(mockSetUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/settings/server-url-bottom-sheet.tsx b/src/components/settings/server-url-bottom-sheet.tsx index 20147d94..714c411b 100644 --- a/src/components/settings/server-url-bottom-sheet.tsx +++ b/src/components/settings/server-url-bottom-sheet.tsx @@ -5,9 +5,8 @@ import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { Platform, ScrollView } from 'react-native'; -import { getSystemConfig } from '@/api/config'; import { logger } from '@/lib/logging'; -import { buildApiUrl, CUSTOM_SERVER_VALUE, toBaseUrl, URL_PATTERN } from '@/lib/server-url'; +import { buildApiUrl, CUSTOM_SERVER_VALUE, findLocationByUrl, isSameServerUrl, loadServerLocations, toBaseUrl, URL_PATTERN } from '@/lib/server-url'; import { type ResgridSystemLocation } from '@/models/v4/configs/getSystemConfigResultData'; import { useServerUrlStore } from '@/stores/app/server-url-store'; @@ -21,6 +20,7 @@ import { Select, SelectBackdrop, SelectContent, SelectDragIndicator, SelectDragI import { Spinner } from '../ui/spinner'; import { Text } from '../ui/text'; import { VStack } from '../ui/vstack'; + interface ServerUrlForm { url: string; } @@ -28,9 +28,12 @@ interface ServerUrlForm { interface ServerUrlBottomSheetProps { isOpen: boolean; onClose: () => void; + // Called after a save that switched to a different server. The session's tokens belong to + // the previous server, so a signed-in caller should log out (as Responder does). + onUrlChanged?: () => Promise | void; } -export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetProps) { +export function ServerUrlBottomSheet({ isOpen, onClose, onUrlChanged }: ServerUrlBottomSheetProps) { const { t } = useTranslation(); const { colorScheme } = useColorScheme(); const [isLoading, setIsLoading] = React.useState(false); @@ -43,6 +46,8 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr control, handleSubmit, setValue, + setError, + clearErrors, formState: { errors }, } = useForm(); @@ -57,25 +62,15 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr const loadServers = async () => { setIsLoadingServers(true); + clearErrors(); try { // The persisted value includes the /api/vX suffix; reduce it to a bare base so we // can match it against a hosted site or show it for editing in the custom field. const currentUrl = await getUrl(); const currentBaseUrl = toBaseUrl(currentUrl); - - let fetchedLocations: ResgridSystemLocation[] = []; - try { - const result = await getSystemConfig(); - fetchedLocations = result?.Data?.Locations ?? []; - } catch (error) { - // The list of hosted sites is best-effort; on failure the user can still enter a - // custom URL manually, so we degrade gracefully instead of blocking the sheet. - logger.error({ - message: 'Failed to load Resgrid hosted sites', - context: { error }, - }); - } + // Always includes the Resgrid hosted sites, even if the current server can't be reached. + const fetchedLocations = await loadServerLocations(); if (isCancelled) { return; @@ -85,7 +80,7 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr // Preselect the hosted site whose API URL matches the persisted URL; otherwise fall // back to the Custom option and show the persisted URL so the user can edit it. - const matchedLocation = fetchedLocations.find((location) => toBaseUrl(location.ApiUrl) === currentBaseUrl); + const matchedLocation = findLocationByUrl(fetchedLocations, currentUrl); if (matchedLocation) { setSelectedServer(matchedLocation.Name); setValue('url', toBaseUrl(matchedLocation.ApiUrl)); @@ -105,7 +100,7 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr return () => { isCancelled = true; }; - }, [isOpen, getUrl, setValue]); + }, [isOpen, getUrl, setValue, clearErrors]); const handleServerChange = React.useCallback( (nextServer: string) => { @@ -130,24 +125,31 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr // the full API URL (with the /api/vX suffix) that the API client expects. const resolvedBaseUrl = isCustomSelected ? data.url : (location?.ApiUrl ?? data.url); const apiUrl = buildApiUrl(resolvedBaseUrl); + const previousUrl = await getUrl(); await setUrl(apiUrl); logger.info({ message: 'Server URL updated successfully', context: { url: apiUrl, server: selectedServer }, }); + + if (onUrlChanged && !isSameServerUrl(previousUrl, apiUrl)) { + await onUrlChanged(); + } + onClose(); } catch (error) { logger.error({ message: 'Failed to update server URL', context: { error }, }); + setError('root', { message: error instanceof Error ? error.message : t('common.error') }); } finally { setIsLoading(false); } }; const selectedLocation = locations.find((location) => location.Name === selectedServer); - const selectedServerLabel = isCustomSelected ? t('settings.custom') : selectedLocation?.DisplayName || selectedLocation?.Name; + const selectedServerLabel = isCustomSelected ? t('settings.custom') : selectedLocation?.Name; return ( @@ -183,7 +185,7 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr {locations.map((location) => ( - + ))} @@ -242,6 +244,12 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr
+ {errors.root?.message ? ( + + {errors.root.message} + + ) : null} +