diff --git a/src/api/calls/__tests__/saveCall.test.ts b/src/api/calls/__tests__/saveCall.test.ts
new file mode 100644
index 0000000..a82ddfe
--- /dev/null
+++ b/src/api/calls/__tests__/saveCall.test.ts
@@ -0,0 +1,69 @@
+import { createCall, updateCall } from '../calls';
+
+jest.mock('../../common/client', () => {
+ const post = jest.fn();
+ const put = jest.fn();
+ return {
+ createApiEndpoint: jest.fn(() => ({ get: jest.fn(), post, put, delete: jest.fn() })),
+ __mockPost: post,
+ __mockPut: put,
+ };
+});
+
+const { __mockPost: mockPost, __mockPut: mockPut } = jest.requireMock('../../common/client') as { __mockPost: jest.Mock; __mockPut: jest.Mock };
+
+const baseCall = { name: 'Structure Fire', nature: 'Smoke showing', priority: 1 };
+
+describe('call save payloads', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockPost.mockResolvedValue({ data: { Id: '42' } });
+ mockPut.mockResolvedValue({ data: { Id: '42' } });
+ });
+
+ describe('createCall (SaveCall)', () => {
+ it('sends the call identifiers', async () => {
+ await createCall({ ...baseCall, externalId: 'CAD-9', incidentId: 'INC-1', referenceId: 'REF-2' });
+
+ expect(mockPost).toHaveBeenCalledWith(expect.objectContaining({ ExternalId: 'CAD-9', IncidentId: 'INC-1', ReferenceId: 'REF-2' }));
+ });
+
+ it('sends blank identifiers when none were entered', async () => {
+ await createCall(baseCall);
+
+ expect(mockPost).toHaveBeenCalledWith(expect.objectContaining({ ExternalId: '', IncidentId: '', ReferenceId: '' }));
+ });
+
+ it('sends DispatchOnUtc only when a dispatch time is set', async () => {
+ await createCall({ ...baseCall, dispatchOnUtc: '2026-10-09T14:30:00.000Z' });
+ expect(mockPost).toHaveBeenLastCalledWith(expect.objectContaining({ DispatchOnUtc: '2026-10-09T14:30:00.000Z' }));
+
+ await createCall(baseCall);
+ expect(mockPost.mock.calls[1][0]).not.toHaveProperty('DispatchOnUtc');
+ });
+
+ it('never sends the picker-only properties this app has no input for', async () => {
+ await createCall({ ...baseCall, dispatchOnUtc: '2026-10-09T14:30:00.000Z' });
+
+ for (const property of ['ProtocolIds', 'LinkedCallId', 'IndoorMapZoneId', 'IndoorMapFloorId']) {
+ expect(mockPost.mock.calls[0][0]).not.toHaveProperty(property);
+ }
+ });
+ });
+
+ describe('updateCall (EditCall)', () => {
+ it('sends the call identifiers, blank meaning "keep what is stored"', async () => {
+ await updateCall({ ...baseCall, callId: '42', externalId: 'CAD-9', incidentId: '', referenceId: 'REF-2' });
+
+ expect(mockPut).toHaveBeenCalledWith(expect.objectContaining({ Id: '42', ExternalId: 'CAD-9', IncidentId: '', ReferenceId: 'REF-2' }));
+ });
+
+ it('sends DispatchOnUtc only when a dispatch time is set, leaving the stored schedule alone otherwise', async () => {
+ await updateCall({ ...baseCall, callId: '42', dispatchOnUtc: '2026-10-09T14:30:00.000Z' });
+ expect(mockPut).toHaveBeenLastCalledWith(expect.objectContaining({ DispatchOnUtc: '2026-10-09T14:30:00.000Z' }));
+
+ await updateCall({ ...baseCall, callId: '42' });
+ expect(mockPut.mock.calls[1][0]).not.toHaveProperty('DispatchOnUtc');
+ });
+ });
+});
diff --git a/src/api/calls/calls.ts b/src/api/calls/calls.ts
index 32d5226..bb3ed53 100644
--- a/src/api/calls/calls.ts
+++ b/src/api/calls/calls.ts
@@ -43,6 +43,17 @@ export interface CreateCallRequest {
type?: string;
contactName?: string;
contactInfo?: string;
+ /** External (CAD) call id. On update a blank value keeps the stored one. */
+ externalId?: string;
+ /** Incident id. On update a blank value keeps the stored one. */
+ incidentId?: string;
+ /** Reference id. On update a blank value keeps the stored one. */
+ referenceId?: string;
+ /**
+ * Scheduled dispatch time, ISO 8601 UTC. Left out of the request when not set: on create the call goes out
+ * now, on update the stored schedule is kept (the API has no way to clear it).
+ */
+ dispatchOnUtc?: string;
/** Primary Contact (premises/customer record) to link; the contact must belong to the department. */
contactId?: string | null;
/** Additional Contacts to link. On update, supplying either list replaces the existing links; omitting both leaves them alone. */
@@ -69,6 +80,17 @@ export interface UpdateCallRequest {
type?: string;
contactName?: string;
contactInfo?: string;
+ /** External (CAD) call id. On update a blank value keeps the stored one. */
+ externalId?: string;
+ /** Incident id. On update a blank value keeps the stored one. */
+ incidentId?: string;
+ /** Reference id. On update a blank value keeps the stored one. */
+ referenceId?: string;
+ /**
+ * Scheduled dispatch time, ISO 8601 UTC. Left out of the request when not set: on create the call goes out
+ * now, on update the stored schedule is kept (the API has no way to clear it).
+ */
+ dispatchOnUtc?: string;
/** Primary Contact (premises/customer record) to link; the contact must belong to the department. */
contactId?: string | null;
/** Additional Contacts to link. On update, supplying either list replaces the existing links; omitting both leaves them alone. */
@@ -128,6 +150,10 @@ export const createCall = async (callData: CreateCallRequest) => {
Type: callData.type || '',
ContactName: callData.contactName || '',
ContactInfo: callData.contactInfo || '',
+ ExternalId: callData.externalId || '',
+ IncidentId: callData.incidentId || '',
+ ReferenceId: callData.referenceId || '',
+ ...(callData.dispatchOnUtc ? { DispatchOnUtc: callData.dispatchOnUtc } : {}),
...(callData.contactId !== undefined ? { ContactId: callData.contactId || '' } : {}),
...(callData.additionalContactIds !== undefined ? { AdditionalContactIds: callData.additionalContactIds } : {}),
What3Words: callData.what3words || '',
@@ -175,6 +201,10 @@ export const updateCall = async (callData: UpdateCallRequest) => {
Type: callData.type || '',
ContactName: callData.contactName || '',
ContactInfo: callData.contactInfo || '',
+ ExternalId: callData.externalId || '',
+ IncidentId: callData.incidentId || '',
+ ReferenceId: callData.referenceId || '',
+ ...(callData.dispatchOnUtc ? { DispatchOnUtc: callData.dispatchOnUtc } : {}),
...(callData.contactId !== undefined ? { ContactId: callData.contactId || '' } : {}),
...(callData.additionalContactIds !== undefined ? { AdditionalContactIds: callData.additionalContactIds } : {}),
What3Words: callData.what3words || '',
diff --git a/src/app/(app)/work-orders/[id].tsx b/src/app/(app)/work-orders/[id].tsx
index c6a432f..2e30027 100644
--- a/src/app/(app)/work-orders/[id].tsx
+++ b/src/app/(app)/work-orders/[id].tsx
@@ -86,7 +86,7 @@ export default function WorkOrderScreen() {
const attach = async (source: 'camera' | 'library') => {
setMessage(null);
try {
- const photo = await capturePhoto(source, `work-order-${order?.Number ?? 'photo'}.jpg`);
+ const photo = await capturePhoto(source, `work-order-${(order?.Number ?? 'photo').replace(/[^A-Za-z0-9._-]+/g, '-')}.jpg`);
if (!photo) return;
const ok = await useWorkOrdersStore.getState().attach({ uri: photo.uri, name: photo.name, type: photo.contentType });
await discardPhoto(photo);
diff --git a/src/app/call/[id]/__tests__/field-policy.test.tsx b/src/app/call/[id]/__tests__/field-policy.test.tsx
new file mode 100644
index 0000000..72e777b
--- /dev/null
+++ b/src/app/call/[id]/__tests__/field-policy.test.tsx
@@ -0,0 +1,554 @@
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react-native';
+import React from 'react';
+
+import { getNewCallFieldPolicy } from '@/api/calls/newCallFieldPolicy';
+import { type NewCallFieldRuleData } from '@/models/v4/calls/newCallFieldPolicyResultData';
+
+// The department's call field policy applies to edits too: the edit screen offers the same fields as the
+// new-call screen, hides the ones the policy hides, marks and checks the required ones against what the
+// save will send, and never clears a hidden field. Runs the real policy hook against a mocked endpoint.
+
+const baseFormValues = {
+ name: 'Structure Fire',
+ nature: 'Smoke showing',
+ note: 'Stored note',
+ address: '123 Main St',
+ coordinates: '39.7392, -104.9903',
+ what3words: 'filled.count.soap',
+ plusCode: '',
+ latitude: 39.7392 as number | undefined,
+ longitude: -104.9903 as number | undefined,
+ priority: 'High',
+ type: 'Fire',
+ destinationPoiId: '7',
+ contactName: 'Jane Caller',
+ contactInfo: '555-0100',
+ externalId: 'CAD-9',
+ incidentId: 'INC-1',
+ referenceId: 'REF-2',
+ dispatchOn: '',
+ dispatchSelection: { everyone: false, users: ['user-1'], groups: [] as string[], roles: [] as string[], units: [] as string[] },
+};
+
+// A whole-second instant some minutes away, as an ISO string ('Z'), and as the zone-less form older
+// servers send for a stored dispatch time.
+const minutesFromNow = (minutes: number) => {
+ const date = new Date(Date.now() + minutes * 60 * 1000);
+ date.setMilliseconds(0);
+ return { iso: date.toISOString(), zoneless: date.toISOString().replace('.000Z', '') };
+};
+
+let mockFormValues = { ...baseFormValues };
+// Receives the values the screen pre-fills the form with from the stored call.
+const mockReset = jest.fn();
+
+// Bypass validation: this suite is about the field policy, not the zod schema. The values stand in for
+// the form as the user leaves it (it starts out holding the stored call).
+jest.mock('react-hook-form', () => ({
+ useForm: () => ({
+ control: {},
+ handleSubmit: (onValid: (values: unknown) => unknown) => () => onValid({ ...mockFormValues }),
+ formState: { errors: {} },
+ setValue: jest.fn(),
+ reset: mockReset,
+ getValues: () => mockFormValues,
+ }),
+ Controller: ({ render: renderField }: { render: (arg: unknown) => React.ReactElement }) => renderField({ field: { onChange: jest.fn(), onBlur: jest.fn(), value: '' } }),
+}));
+
+jest.mock('@hookform/resolvers/zod', () => ({ zodResolver: () => jest.fn() }));
+
+// Stable across renders (effects depend on it); shows interpolated values so the toast can be read.
+const mockT = (key: string, options?: { fields?: string; minutes?: number }) => (options?.fields !== undefined ? `${key}|${options.fields}` : options?.minutes !== undefined ? `${key}|${options.minutes}` : key);
+jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: mockT }) }));
+
+jest.mock('expo-router', () => ({
+ router: { push: jest.fn(), back: jest.fn() },
+ Stack: { Screen: () => null },
+ useFocusEffect: jest.fn(),
+ useLocalSearchParams: () => ({ id: 'call-1' }),
+}));
+
+jest.mock('@/api/calls/newCallFieldPolicy', () => ({ getNewCallFieldPolicy: jest.fn() }));
+jest.mock('@/api/dispatch', () => ({ getNewCallData: jest.fn().mockResolvedValue({ Data: { DestinationPois: [], PoiTypes: [] } }) }));
+
+jest.mock('@/lib/logging', () => ({ logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn(), debug: jest.fn() } }));
+
+const mockTrackEvent = jest.fn();
+jest.mock('@/hooks/use-analytics', () => ({ useAnalytics: () => ({ trackEvent: mockTrackEvent }) }));
+
+const mockToastShow = jest.fn();
+jest.mock('@/components/ui/toast', () => ({ useToast: () => ({ show: mockToastShow }) }));
+
+// The stored call has a location, so the map preview renders instead of the "select location" button.
+jest.mock('@/components/maps/location-picker', () => {
+ const ReactModule = require('react');
+ const { View } = require('react-native');
+ return () => ReactModule.createElement(View, { testID: 'location-picker' });
+});
+jest.mock('@/components/maps/full-screen-location-picker', () => 'FullScreenLocationPicker');
+jest.mock('@/components/calls/dispatch-selection-modal', () => ({ DispatchSelectionModal: () => null }));
+jest.mock('@/components/common/header-back-button', () => ({ HeaderBackButton: () => null }));
+
+jest.mock('@/components/ui/select', () => {
+ const ReactModule = require('react');
+ const { View } = require('react-native');
+ const passthrough =
+ (testID: string) =>
+ ({ children }: { children?: React.ReactNode }) =>
+ ReactModule.createElement(View, { testID }, children);
+
+ return {
+ Select: passthrough('select'),
+ SelectTrigger: passthrough('select-trigger'),
+ SelectInput: () => null,
+ SelectIcon: () => null,
+ SelectPortal: () => null,
+ SelectBackdrop: () => null,
+ SelectContent: passthrough('select-content'),
+ SelectItem: () => null,
+ };
+});
+
+jest.mock('@/components/ui/bottom-sheet', () => ({ CustomBottomSheet: () => null }));
+
+jest.mock('lucide-react-native', () => ({
+ ChevronDownIcon: () => null,
+ PlusIcon: () => null,
+ SearchIcon: () => null,
+}));
+
+jest.mock('nativewind', () => ({
+ useColorScheme: () => ({ colorScheme: 'light' }),
+ cssInterop: jest.fn(),
+ styled: jest.fn((Component: unknown) => Component),
+}));
+
+const mockCallsState = {
+ callPriorities: [{ Id: 1, Name: 'High', Color: '#ff0000' }],
+ callTypes: [{ Id: 1, Name: 'Fire' }],
+ isLoading: false,
+ error: null,
+ fetchCallPriorities: jest.fn(),
+ fetchCallTypes: jest.fn(),
+};
+
+jest.mock('@/stores/calls/store', () => {
+ const useCallsStore = (selector?: (state: unknown) => unknown) => (selector ? selector(mockCallsState) : mockCallsState);
+ useCallsStore.getState = () => mockCallsState;
+ return { useCallsStore };
+});
+
+const mockUpdateCall = jest.fn();
+
+const baseCall = {
+ CallId: 'call-1',
+ Name: 'Structure Fire',
+ Nature: 'Smoke showing',
+ Note: '
Stored note
',
+ Address: '123 Main St',
+ Geolocation: '39.7392,-104.9903',
+ Latitude: '39.7392',
+ Longitude: '-104.9903',
+ Priority: 1,
+ Type: 'Fire',
+ What3Words: 'filled.count.soap',
+ ContactName: 'Jane Caller',
+ ContactInfo: '555-0100',
+ ExternalId: 'CAD-9',
+ IncidentId: 'INC-1',
+ ReferenceId: 'REF-2',
+ DestinationPoiId: 42 as number | undefined,
+ State: 0 as number | string,
+ // Sent an hour ago: not a schedule.
+ DispatchedOnUtc: minutesFromNow(-60).zoneless,
+};
+
+const mockDetailState = {
+ call: { ...baseCall },
+ callExtraData: { Dispatches: [] },
+ isLoading: false,
+ error: null,
+ fetchCallDetail: jest.fn(),
+ updateCall: mockUpdateCall,
+};
+
+jest.mock('@/stores/calls/detail-store', () => {
+ const useCallDetailStore = (selector?: (state: unknown) => unknown) => (selector ? selector(mockDetailState) : mockDetailState);
+ useCallDetailStore.getState = () => mockDetailState;
+ return { useCallDetailStore };
+});
+
+jest.mock('@/stores/app/core-store', () => {
+ const coreState = { config: { GoogleMapsKey: '', W3WKey: '' } };
+ return { useCoreStore: (selector?: (state: unknown) => unknown) => (selector ? selector(coreState) : coreState) };
+});
+
+const mockedGetPolicy = getNewCallFieldPolicy as jest.MockedFunction;
+
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const EditCall = require('../edit').default as React.ComponentType;
+
+const withPolicy = (rules: NewCallFieldRuleData[]) => mockedGetPolicy.mockResolvedValue({ Rules: rules });
+
+// Every toast on this screen renders a box holding a single line of text; read that text back.
+const toastMessages = (): string[] =>
+ mockToastShow.mock.calls.map(([options]) => {
+ const box = (options as { render: (props: { id: string }) => React.ReactElement }).render({ id: 'toast' }) as React.ReactElement<{ children: React.ReactElement<{ children: string }> }>;
+ return box.props.children.props.children;
+ });
+
+const FIELD_TEST_IDS = [
+ 'note-field',
+ 'destination-field',
+ 'address-field',
+ 'coordinates-field',
+ 'what3words-field',
+ 'plus-code-field',
+ 'location-picker',
+ 'contact-name-field',
+ 'contact-info-field',
+ 'external-id-field',
+ 'incident-id-field',
+ 'reference-id-field',
+ 'dispatch-on-field',
+ 'open-dispatch-modal-button',
+];
+
+// The Save button stays disabled until the policy has loaded, so wait for that before pressing it.
+const renderLoaded = async () => {
+ render();
+ const saveButton = await screen.findByTestId('save-call-button');
+ await waitFor(() => expect(mockedGetPolicy).toHaveBeenCalled());
+ await waitFor(() => expect(saveButton.props.accessibilityState?.disabled).toBeFalsy());
+ return saveButton;
+};
+
+describe('Edit call field policy', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockFormValues = { ...baseFormValues, dispatchSelection: { ...baseFormValues.dispatchSelection } };
+ mockDetailState.call = { ...baseCall };
+ mockUpdateCall.mockResolvedValue(undefined);
+ });
+
+ it('offers every field the new-call screen offers when the department has no policy', async () => {
+ withPolicy([]);
+
+ await renderLoaded();
+
+ for (const testID of FIELD_TEST_IDS) {
+ expect(screen.getByTestId(testID)).toBeTruthy();
+ }
+ });
+
+ it('hides every field the policy hides', async () => {
+ withPolicy(
+ ['note', 'destinationPoi', 'address', 'geolocation', 'what3words', 'pluscode', 'contactName', 'contactInfo', 'externalId', 'incidentId', 'referenceId', 'dispatchOn', 'dispatchList'].map((key) => ({
+ Key: key,
+ Visible: false,
+ Required: false,
+ }))
+ );
+
+ await renderLoaded();
+
+ for (const testID of FIELD_TEST_IDS) {
+ expect(screen.queryByTestId(testID)).toBeNull();
+ }
+ expect(screen.queryByText('calls.call_location')).toBeNull();
+ expect(screen.queryByTestId('call-identifiers-card')).toBeNull();
+ });
+
+ it('pre-fills the identifiers from the stored call', async () => {
+ withPolicy([]);
+
+ await renderLoaded();
+
+ expect(mockReset).toHaveBeenCalledWith(expect.objectContaining({ externalId: 'CAD-9', incidentId: 'INC-1', referenceId: 'REF-2' }));
+ });
+
+ it('marks required fields', async () => {
+ withPolicy([
+ { Key: 'note', Visible: true, Required: true },
+ { Key: 'what3words', Visible: true, Required: true },
+ { Key: 'dispatchList', Visible: true, Required: true },
+ ]);
+
+ await renderLoaded();
+
+ expect(within(screen.getByTestId('note-field')).getByText('*')).toBeTruthy();
+ expect(within(screen.getByTestId('what3words-field')).getByText('*')).toBeTruthy();
+ expect(within(screen.getByTestId('address-field')).queryByText('*')).toBeNull();
+ expect(screen.getByText('calls.dispatch_to *')).toBeTruthy();
+ });
+
+ it('refuses to save while a required identifier is blank', async () => {
+ withPolicy([
+ { Key: 'externalId', Visible: true, Required: true },
+ { Key: 'incidentId', Visible: true, Required: true },
+ { Key: 'referenceId', Visible: true, Required: true },
+ ]);
+ mockFormValues = { ...mockFormValues, externalId: '', referenceId: ' ' };
+
+ const saveButton = await renderLoaded();
+
+ expect(within(screen.getByTestId('incident-id-field')).getByText('*')).toBeTruthy();
+
+ fireEvent.press(saveButton);
+
+ await waitFor(() => expect(toastMessages()).toContain('calls.required_fields_missing|calls.external_id, calls.reference_id'));
+ expect(mockUpdateCall).not.toHaveBeenCalled();
+ });
+
+ it('refuses to save while a required field is blank, naming it by its label', async () => {
+ withPolicy([
+ { Key: 'contactInfo', Visible: true, Required: true },
+ { Key: 'destinationPoi', Visible: true, Required: true },
+ { Key: 'address', Visible: true, Required: true },
+ ]);
+ mockFormValues = { ...mockFormValues, contactInfo: ' ', destinationPoiId: 'none' };
+
+ const saveButton = await renderLoaded();
+ fireEvent.press(saveButton);
+
+ await waitFor(() => expect(toastMessages()).toContain('calls.required_fields_missing|calls.contact_info, calls.destination'));
+ expect(mockUpdateCall).not.toHaveBeenCalled();
+ });
+
+ it('checks the values the form will send, which start out as the stored call', async () => {
+ withPolicy(
+ ['note', 'address', 'geolocation', 'what3words', 'contactName', 'contactInfo', 'externalId', 'incidentId', 'referenceId', 'destinationPoi', 'dispatchList'].map((key) => ({
+ Key: key,
+ Visible: true,
+ Required: true,
+ }))
+ );
+
+ const saveButton = await renderLoaded();
+ fireEvent.press(saveButton);
+
+ await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1));
+ });
+
+ it('does not hold an edit to fields it cannot fill in', async () => {
+ // The dispatch time is never required of an edit; the rest have no input on this screen and this app
+ // never sends ProtocolIds, LinkedCallId or IndoorMapZoneId, so the server leaves them alone too.
+ // A plus code is never stored on a call.
+ withPolicy(
+ ['dispatchOn', 'protocols', 'linkedCall', 'indoorLocation', 'pluscode'].map((key) => ({
+ Key: key,
+ Visible: true,
+ Required: true,
+ }))
+ );
+
+ const saveButton = await renderLoaded();
+ fireEvent.press(saveButton);
+
+ await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1));
+ const payload = mockUpdateCall.mock.calls[0]?.[0];
+ expect(payload).not.toHaveProperty('protocolIds');
+ expect(payload).not.toHaveProperty('linkedCallId');
+ expect(payload).not.toHaveProperty('indoorMapZoneId');
+ });
+
+ it('requires a dispatch list of an active call but not of a pending one', async () => {
+ withPolicy([{ Key: 'dispatchList', Visible: true, Required: true }]);
+ mockFormValues = { ...mockFormValues, dispatchSelection: { everyone: false, users: [], groups: [], roles: [], units: [] } };
+
+ const saveButton = await renderLoaded();
+ fireEvent.press(saveButton);
+
+ await waitFor(() => expect(toastMessages()).toContain('calls.required_fields_missing|calls.dispatch_to'));
+ expect(mockUpdateCall).not.toHaveBeenCalled();
+
+ screen.unmount();
+ mockToastShow.mockClear();
+ mockDetailState.call = { ...baseCall, State: 8 };
+
+ const pendingSaveButton = await renderLoaded();
+ fireEvent.press(pendingSaveButton);
+
+ await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1));
+ });
+
+ it('sends hidden fields as unchanged instead of clearing them', async () => {
+ withPolicy(
+ ['note', 'address', 'geolocation', 'what3words', 'contactName', 'contactInfo', 'externalId', 'incidentId', 'referenceId', 'destinationPoi', 'dispatchList'].map((key) => ({
+ Key: key,
+ Visible: false,
+ Required: true,
+ }))
+ );
+
+ const saveButton = await renderLoaded();
+ fireEvent.press(saveButton);
+
+ await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1));
+ const payload = mockUpdateCall.mock.calls[0]?.[0];
+
+ // Blank text keeps the stored value on EditCall.
+ expect(payload).toEqual(expect.objectContaining({ note: '', address: '', what3words: '', contactName: '', contactInfo: '', externalId: '', incidentId: '', referenceId: '' }));
+ // No point keeps the stored point.
+ expect(payload).not.toHaveProperty('latitude');
+ expect(payload).not.toHaveProperty('longitude');
+ // The destination is always overwritten, so it goes back as stored rather than as the form holds it.
+ expect(payload.destinationPoiId).toBe(42);
+ // The recipients go back as loaded: a blank list means "everyone" to older servers.
+ expect(payload).toEqual(expect.objectContaining({ dispatchEveryone: false, dispatchUsers: ['user-1'] }));
+ });
+
+ it('sends visible fields as the form holds them', async () => {
+ withPolicy([]);
+
+ const saveButton = await renderLoaded();
+ fireEvent.press(saveButton);
+
+ await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1));
+ expect(mockUpdateCall.mock.calls[0]?.[0]).toEqual(
+ expect.objectContaining({
+ note: 'Stored note',
+ address: '123 Main St',
+ what3words: 'filled.count.soap',
+ contactName: 'Jane Caller',
+ contactInfo: '555-0100',
+ externalId: 'CAD-9',
+ incidentId: 'INC-1',
+ referenceId: 'REF-2',
+ destinationPoiId: 7,
+ latitude: 39.7392,
+ longitude: -104.9903,
+ })
+ );
+ });
+
+ it('names the fields when the server refuses the save for them', async () => {
+ withPolicy([]);
+ mockUpdateCall.mockRejectedValueOnce({ isAxiosError: true, response: { status: 400, data: 'Required call fields are missing: incidentId, referenceId' } });
+
+ const saveButton = await renderLoaded();
+ fireEvent.press(saveButton);
+
+ await waitFor(() => expect(toastMessages()).toContain('calls.required_fields_missing|calls.incident_id, calls.reference_id'));
+ });
+
+ describe('scheduled dispatch', () => {
+ it('never requires or marks a dispatch time on an edit', async () => {
+ withPolicy([{ Key: 'dispatchOn', Visible: true, Required: true }]);
+
+ const saveButton = await renderLoaded();
+
+ expect(screen.getByTestId('dispatch-on-input')).toBeTruthy();
+ expect(within(screen.getByTestId('dispatch-on-field')).queryByText('*')).toBeNull();
+
+ fireEvent.press(saveButton);
+
+ await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1));
+ expect(mockUpdateCall.mock.calls[0]?.[0]).not.toHaveProperty('dispatchOnUtc');
+ });
+
+ it('pre-fills the stored dispatch time, read as UTC, only while the call is still scheduled', async () => {
+ withPolicy([]);
+ const scheduled = minutesFromNow(45);
+ mockDetailState.call = { ...baseCall, DispatchedOnUtc: scheduled.zoneless };
+
+ await renderLoaded();
+
+ expect(mockReset).toHaveBeenCalledWith(expect.objectContaining({ dispatchOn: scheduled.iso }));
+
+ screen.unmount();
+ mockReset.mockClear();
+ mockDetailState.call = { ...baseCall };
+
+ await renderLoaded();
+
+ expect(mockReset).toHaveBeenCalledWith(expect.objectContaining({ dispatchOn: '' }));
+ });
+
+ it('offers clear only when there is no stored schedule, since EditCall cannot remove one', async () => {
+ withPolicy([]);
+
+ await renderLoaded();
+ fireEvent.press(screen.getByTestId('dispatch-on-input'));
+ expect(screen.getByTestId('dispatch-on-input-clear')).toBeTruthy();
+
+ screen.unmount();
+ mockDetailState.call = { ...baseCall, DispatchedOnUtc: minutesFromNow(45).zoneless };
+
+ await renderLoaded();
+ fireEvent.press(screen.getByTestId('dispatch-on-input'));
+ expect(screen.getByTestId('dispatch-on-input-done')).toBeTruthy();
+ expect(screen.queryByTestId('dispatch-on-input-clear')).toBeNull();
+ });
+
+ it('saves an untouched stored schedule without resending it, even inside the lead time', async () => {
+ withPolicy([]);
+ const scheduled = minutesFromNow(10);
+ mockDetailState.call = { ...baseCall, DispatchedOnUtc: scheduled.zoneless };
+ mockFormValues = { ...mockFormValues, dispatchOn: scheduled.iso };
+
+ const saveButton = await renderLoaded();
+ fireEvent.press(saveButton);
+
+ await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1));
+ expect(mockUpdateCall.mock.calls[0]?.[0]).not.toHaveProperty('dispatchOnUtc');
+ });
+
+ it('refuses a new dispatch time less than 15 minutes ahead', async () => {
+ withPolicy([]);
+ mockFormValues = { ...mockFormValues, dispatchOn: minutesFromNow(14).iso };
+
+ const saveButton = await renderLoaded();
+ fireEvent.press(saveButton);
+
+ await waitFor(() => expect(toastMessages()).toContain('calls.dispatch_on_too_soon|15'));
+ expect(mockUpdateCall).not.toHaveBeenCalled();
+ });
+
+ it('sends a new dispatch time as DispatchOnUtc', async () => {
+ withPolicy([]);
+ const scheduled = minutesFromNow(45);
+ const rescheduled = minutesFromNow(120);
+ mockDetailState.call = { ...baseCall, DispatchedOnUtc: scheduled.zoneless };
+ mockFormValues = { ...mockFormValues, dispatchOn: rescheduled.iso };
+
+ const saveButton = await renderLoaded();
+ fireEvent.press(saveButton);
+
+ await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1));
+ expect(mockUpdateCall.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ dispatchOnUtc: rescheduled.iso }));
+ });
+
+ it('sends nothing for a hidden dispatch time', async () => {
+ withPolicy([{ Key: 'dispatchOn', Visible: false, Required: false }]);
+ mockFormValues = { ...mockFormValues, dispatchOn: minutesFromNow(120).iso };
+
+ const saveButton = await renderLoaded();
+ fireEvent.press(saveButton);
+
+ await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1));
+ expect(mockUpdateCall.mock.calls[0]?.[0]).not.toHaveProperty('dispatchOnUtc');
+ });
+ });
+
+ it('shows the generic error for any other failure', async () => {
+ withPolicy([]);
+ mockUpdateCall.mockRejectedValueOnce(new Error('network down'));
+
+ const saveButton = await renderLoaded();
+ fireEvent.press(saveButton);
+
+ await waitFor(() => expect(toastMessages()).toContain('call_detail.update_call_error'));
+ });
+
+ it('does not save before the policy has loaded', async () => {
+ mockedGetPolicy.mockReturnValue(new Promise(() => undefined));
+
+ render();
+ fireEvent.press(await screen.findByTestId('save-call-button'));
+
+ await waitFor(() => expect(mockedGetPolicy).toHaveBeenCalled());
+ expect(mockUpdateCall).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/call/[id]/__tests__/save-submit-guard.test.tsx b/src/app/call/[id]/__tests__/save-submit-guard.test.tsx
index 16c492c..b3d00da 100644
--- a/src/app/call/[id]/__tests__/save-submit-guard.test.tsx
+++ b/src/app/call/[id]/__tests__/save-submit-guard.test.tsx
@@ -52,6 +52,15 @@ jest.mock('@/hooks/use-analytics', () => ({ useAnalytics: () => ({ trackEvent: m
const mockToastShow = jest.fn();
jest.mock('@/components/ui/toast', () => ({ useToast: () => ({ show: mockToastShow }) }));
+jest.mock('@/hooks/use-new-call-field-policy', () => ({
+ useNewCallFieldPolicy: () => ({
+ isLoaded: true,
+ isVisible: () => true,
+ isRequired: () => false,
+ missingRequired: () => [],
+ }),
+}));
+
jest.mock('@/components/maps/location-picker', () => 'LocationPicker');
jest.mock('@/components/maps/full-screen-location-picker', () => 'FullScreenLocationPicker');
jest.mock('@/components/calls/dispatch-selection-modal', () => ({ DispatchSelectionModal: () => null }));
diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx
index b8f7271..3d7c5aa 100644
--- a/src/app/call/[id]/edit.tsx
+++ b/src/app/call/[id]/edit.tsx
@@ -14,6 +14,7 @@ import * as z from 'zod';
import { getNewCallData } from '@/api/dispatch';
import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal';
+import { DateTimeField } from '@/components/common/date-time-field';
import { HeaderBackButton } from '@/components/common/header-back-button';
import { Loading } from '@/components/common/loading';
import FullScreenLocationPicker from '@/components/maps/full-screen-location-picker';
@@ -29,8 +30,21 @@ import { Text } from '@/components/ui/text';
import { Textarea, TextareaInput } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/toast';
import { useAnalytics } from '@/hooks/use-analytics';
+import { useNewCallFieldPolicy } from '@/hooks/use-new-call-field-policy';
+import {
+ CALL_FORM_UNCOLLECTED_FIELD_KEYS,
+ formatCallFieldLabels,
+ formatCallGeolocation,
+ getMissingCallFieldsFromError,
+ getScheduledDispatchPrefill,
+ isPendingCallState,
+ isScheduledDispatchTooSoon,
+ MIN_SCHEDULED_DISPATCH_LEAD_MINUTES,
+ toDispatchOnUtc,
+} from '@/lib/call-field-policy';
import { logger } from '@/lib/logging';
import { getDestinationPoiIdFromValue, getDestinationPoiSelectOptions, NO_DESTINATION_POI_VALUE } from '@/lib/poi';
+import { NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData';
import { type PoiResultData } from '@/models/v4/mapping/poiResultData';
import { type PoiTypeResultData } from '@/models/v4/mapping/poiTypeResultData';
import { useCoreStore } from '@/stores/app/core-store';
@@ -54,6 +68,11 @@ const formSchema = z.object({
destinationPoiId: z.string().optional(),
contactName: z.string().optional(),
contactInfo: z.string().optional(),
+ externalId: z.string().optional(),
+ incidentId: z.string().optional(),
+ referenceId: z.string().optional(),
+ // Scheduled dispatch time as an ISO 8601 UTC string; '' for "dispatch now" / "leave the schedule alone".
+ dispatchOn: z.string().optional(),
dispatchSelection: z.object({
everyone: z.boolean(),
users: z.array(z.string()),
@@ -99,6 +118,15 @@ interface GeocodingResponse {
status: string;
}
+// The fields of a what3words convert-to-coordinates answer this screen reads.
+interface What3WordsResponse {
+ coordinates?: {
+ lng: number;
+ lat: number;
+ };
+ nearestPlace?: string;
+}
+
export default function EditCall() {
const { t } = useTranslation();
const { colorScheme } = useColorScheme();
@@ -137,6 +165,28 @@ export default function EditCall() {
const toast = useToast();
const toastRef = useRef(toast);
toastRef.current = toast;
+ // A toast in the style this screen already uses, for the messages that only carry text.
+ const showToast = useCallback((tone: 'success' | 'error' | 'warning' | 'info', message: string) => {
+ const background = tone === 'success' ? 'bg-green-500' : tone === 'error' ? 'bg-red-500' : tone === 'warning' ? 'bg-orange-500' : 'bg-blue-500';
+
+ toastRef.current.show({
+ placement: 'top',
+ render: () => {
+ return (
+
+ {message}
+
+ );
+ },
+ });
+ }, []);
+ // The department's call field policy applies to edits too: the fields it hides stay hidden, and the
+ // ones it requires must still be filled in when the call is saved. The server checks the same rules
+ // against the call as it will be after the edit.
+ const fieldPolicy = useNewCallFieldPolicy();
+ // The stored dispatch time while the call is still waiting to go out, as the form was pre-filled with it.
+ // Saving it back unchanged is not a new schedule: it is left out, and is not held to the lead-time rule.
+ const [storedDispatchOn, setStoredDispatchOn] = useState('');
const [showLocationPicker, setShowLocationPicker] = useState(false);
const [showDispatchModal, setShowDispatchModal] = useState(false);
const [showAddressSelection, setShowAddressSelection] = useState(false);
@@ -193,6 +243,10 @@ export default function EditCall() {
destinationPoiId: NO_DESTINATION_POI_VALUE,
contactName: '',
contactInfo: '',
+ externalId: '',
+ incidentId: '',
+ referenceId: '',
+ dispatchOn: '',
dispatchSelection: {
everyone: false,
users: [],
@@ -338,13 +392,19 @@ export default function EditCall() {
// Update local state for dispatch selection
setDispatchSelection(initialDispatchSelection);
+ // Only a call that is scheduled and not sent yet has a dispatch time worth showing; the dispatch time
+ // of a call already sent is history, not a schedule.
+ const scheduledDispatchOn = getScheduledDispatchPrefill(call.DispatchedOnUtc);
+ setStoredDispatchOn(scheduledDispatchOn);
+
reset({
name: call.Name || '',
nature: stripHtml(call.Nature),
note: stripHtml(call.Note),
address: call.Address || '',
coordinates: call.Geolocation || '',
- what3words: '',
+ what3words: call.What3Words || '',
+ // A plus code is only a way to look a location up; calls do not store one.
plusCode: '',
latitude: call.Latitude ? parseFloat(call.Latitude) : undefined,
longitude: call.Longitude ? parseFloat(call.Longitude) : undefined,
@@ -353,6 +413,10 @@ export default function EditCall() {
destinationPoiId: call.DestinationPoiId ? call.DestinationPoiId.toString() : NO_DESTINATION_POI_VALUE,
contactName: call.ContactName || '',
contactInfo: call.ContactInfo || '',
+ externalId: call.ExternalId || '',
+ incidentId: call.IncidentId || '',
+ referenceId: call.ReferenceId || '',
+ dispatchOn: scheduledDispatchOn,
dispatchSelection: initialDispatchSelection,
});
@@ -403,6 +467,14 @@ export default function EditCall() {
setIsSubmitting(true);
try {
+ // Until the policy lands it reads as "nothing hidden, nothing required", so a save in that
+ // window would both skip the department's rules and send fields it hides. Hold it back, as the
+ // new-call screen does.
+ if (!fieldPolicy.isLoaded) {
+ showToast('error', t('calls.field_policy_loading'));
+ return;
+ }
+
const destinationPoiId = getDestinationPoiIdFromValue(data.destinationPoiId);
// If we have latitude and longitude, add them to the data
@@ -411,6 +483,51 @@ export default function EditCall() {
data.longitude = selectedLocation.longitude;
}
+ const dispatchSelectionValue = data.dispatchSelection;
+ const hasDispatchList =
+ !!dispatchSelectionValue &&
+ (dispatchSelectionValue.everyone || dispatchSelectionValue.users.length > 0 || dispatchSelectionValue.groups.length > 0 || dispatchSelectionValue.roles.length > 0 || dispatchSelectionValue.units.length > 0);
+
+ // The same required check as a new call, run against what this save will send (the form starts
+ // out holding the stored call). Skipped, as the server skips them on an edit:
+ // - fields this app has no input for (see CALL_FORM_UNCOLLECTED_FIELD_KEYS);
+ // - the dispatch time, which is never required of an edit;
+ // - the plus code, which only looks a location up and is never stored on a call;
+ // - the dispatch list of a pending call, which nobody has been sent yet.
+ const isPending = isPendingCallState(call?.State);
+ const missingFields = fieldPolicy
+ .missingRequired({
+ [NewCallFieldKeys.Address]: data.address,
+ [NewCallFieldKeys.Geolocation]: formatCallGeolocation(data.latitude, data.longitude),
+ [NewCallFieldKeys.What3Words]: data.what3words,
+ [NewCallFieldKeys.Note]: data.note,
+ [NewCallFieldKeys.ContactName]: data.contactName,
+ [NewCallFieldKeys.ContactInfo]: data.contactInfo,
+ [NewCallFieldKeys.ExternalId]: data.externalId,
+ [NewCallFieldKeys.IncidentId]: data.incidentId,
+ [NewCallFieldKeys.ReferenceId]: data.referenceId,
+ [NewCallFieldKeys.DestinationPoi]: destinationPoiId != null ? String(destinationPoiId) : '',
+ [NewCallFieldKeys.DispatchList]: hasDispatchList,
+ })
+ .filter((key) => !CALL_FORM_UNCOLLECTED_FIELD_KEYS.has(key) && key !== NewCallFieldKeys.DispatchOn && key !== NewCallFieldKeys.PlusCode && !(isPending && key === NewCallFieldKeys.DispatchList));
+
+ if (missingFields.length > 0) {
+ showToast('error', t('calls.required_fields_missing', { fields: formatCallFieldLabels(missingFields, t) }));
+ return;
+ }
+
+ // A new dispatch time reschedules the call, so it has to leave time to change the call before it goes
+ // out (the web form's rule). An empty or untouched input sends nothing and keeps the stored schedule;
+ // the API has no way to clear one.
+ const reschedule = fieldPolicy.isVisible(NewCallFieldKeys.DispatchOn) && !!data.dispatchOn && data.dispatchOn !== storedDispatchOn;
+
+ if (reschedule && isScheduledDispatchTooSoon(data.dispatchOn ?? '')) {
+ showToast('error', t('calls.dispatch_on_too_soon', { minutes: MIN_SCHEDULED_DISPATCH_LEAD_MINUTES }));
+ return;
+ }
+
+ const dispatchOnUtc = reschedule ? toDispatchOnUtc(data.dispatchOn) : undefined;
+
const priority = data.priority ? callPriorities.find((p) => p.Name === data.priority) : null;
const type = data.type ? callTypes.find((t) => t.Name === data.type) : null;
@@ -445,6 +562,14 @@ export default function EditCall() {
dispatchCount: (data.dispatchSelection?.users.length || 0) + (data.dispatchSelection?.groups.length || 0) + (data.dispatchSelection?.roles.length || 0) + (data.dispatchSelection?.units.length || 0),
});
+ // A field the policy hides must come through the save untouched. For EditCall's text fields a
+ // blank value means "keep what is stored", and a blank geolocation keeps the stored point; the
+ // destination is always overwritten, so a hidden one is sent back as stored. (Newer servers
+ // also ignore whatever is posted for a hidden field.) The dispatch list is sent as loaded: a
+ // hidden list cannot be changed here, and a blank one would mean "everyone" to older servers.
+ const isShown = fieldPolicy.isVisible;
+ const sendLocation = isShown(NewCallFieldKeys.Geolocation);
+
// Update the call using the store
const updatePayload = {
callId: callId!,
@@ -453,15 +578,19 @@ export default function EditCall() {
priority: priority?.Id || 0,
// The API matches the call type by its text, not its id.
type: type?.Name || '',
- note: data.note || '',
- address: data.address || '',
- destinationPoiId: destinationPoiId,
- ...(data.latitude != null && { latitude: data.latitude }),
- ...(data.longitude != null && { longitude: data.longitude }),
- what3words: data.what3words || '',
+ note: isShown(NewCallFieldKeys.Note) ? data.note || '' : '',
+ address: isShown(NewCallFieldKeys.Address) ? data.address || '' : '',
+ destinationPoiId: isShown(NewCallFieldKeys.DestinationPoi) ? destinationPoiId : (call?.DestinationPoiId ?? null),
+ ...(sendLocation && data.latitude != null && { latitude: data.latitude }),
+ ...(sendLocation && data.longitude != null && { longitude: data.longitude }),
+ what3words: isShown(NewCallFieldKeys.What3Words) ? data.what3words || '' : '',
plusCode: data.plusCode || '',
- contactName: data.contactName || '',
- contactInfo: data.contactInfo || '',
+ contactName: isShown(NewCallFieldKeys.ContactName) ? data.contactName || '' : '',
+ contactInfo: isShown(NewCallFieldKeys.ContactInfo) ? data.contactInfo || '' : '',
+ externalId: isShown(NewCallFieldKeys.ExternalId) ? data.externalId || '' : '',
+ incidentId: isShown(NewCallFieldKeys.IncidentId) ? data.incidentId || '' : '',
+ referenceId: isShown(NewCallFieldKeys.ReferenceId) ? data.referenceId || '' : '',
+ ...(dispatchOnUtc ? { dispatchOnUtc } : {}),
dispatchUsers: data.dispatchSelection?.users || [],
dispatchGroups: data.dispatchSelection?.groups || [],
dispatchRoles: data.dispatchSelection?.roles || [],
@@ -508,23 +637,19 @@ export default function EditCall() {
error: error instanceof Error ? error.message : 'Unknown error',
});
+ // The server enforces the department's field policy against the call as it will be after the
+ // edit, including fields this screen has no input for. Name them rather than showing a generic
+ // failure.
+ const rejectedFields = getMissingCallFieldsFromError(error);
+
// Show error toast
- toast.show({
- placement: 'top',
- render: () => {
- return (
-
- {t('call_detail.update_call_error')}
-
- );
- },
- });
+ showToast('error', rejectedFields ? t('calls.required_fields_missing', { fields: formatCallFieldLabels(rejectedFields, t) }) : t('call_detail.update_call_error'));
} finally {
isSubmittingRef.current = false;
setIsSubmitting(false);
}
},
- [selectedLocation, callPriorities, callTypes, callId, safeTrack, toast, t]
+ [selectedLocation, callPriorities, callTypes, callId, call, fieldPolicy, storedDispatchOn, safeTrack, showToast, toast, t]
);
const handleSavePress = useCallback(async () => {
@@ -545,6 +670,9 @@ export default function EditCall() {
setValue('address', location.address);
}
+ // Keep the coordinates input in step with the map, as the new-call screen does.
+ setValue('coordinates', `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`);
+
// Analytics: Track location selection
safeTrack('call_edit_location_selected', {
timestamp: new Date().toISOString(),
@@ -745,6 +873,136 @@ export default function EditCall() {
});
};
+ // Coordinates, what3words and plus code lookups (same behaviour as New Call), so an edit offers every
+ // location field a new call does.
+ const handleCoordinatesSearch = async (coordinates: string) => {
+ if (!coordinates.trim()) {
+ showToast('warning', t('calls.coordinates_required'));
+ return;
+ }
+
+ // Expect "40.7128, -74.0060" or "40.7128,-74.0060"
+ const match = coordinates.trim().match(/^(-?\d+\.?\d*),?\s*(-?\d+\.?\d*)$/);
+
+ if (!match) {
+ showToast('warning', t('calls.coordinates_invalid_format'));
+ return;
+ }
+
+ const latitude = parseFloat(match[1] || '0');
+ const longitude = parseFloat(match[2] || '0');
+
+ if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
+ showToast('warning', t('calls.coordinates_out_of_range'));
+ return;
+ }
+
+ safeTrack('call_edit_coordinates_search_attempted', { timestamp: new Date().toISOString(), callId: callId || '' });
+
+ setIsGeocodingCoordinates(true);
+ try {
+ const apiKey = config?.GoogleMapsKey;
+
+ if (!apiKey) {
+ throw new Error('Google Maps API key not configured');
+ }
+
+ const response = await axios.get(`https://maps.googleapis.com/maps/api/geocode/json?latlng=${latitude},${longitude}&key=${apiKey}`);
+ const result = response.data.status === 'OK' ? response.data.results[0] : undefined;
+
+ // The point is what was asked for; a reverse-geocoded address is a bonus.
+ handleLocationSelected(result ? { latitude, longitude, address: result.formatted_address } : { latitude, longitude });
+ showToast(result ? 'success' : 'info', t(result ? 'calls.coordinates_found' : 'calls.coordinates_no_address'));
+ } catch (error) {
+ logger.error({ message: 'Error reverse geocoding coordinates', context: { callId, error } });
+
+ // Even if geocoding fails, still set the location on the map
+ handleLocationSelected({ latitude, longitude });
+ showToast('warning', t('calls.coordinates_geocoding_error'));
+ } finally {
+ setIsGeocodingCoordinates(false);
+ }
+ };
+
+ const handleWhat3WordsSearch = async (what3words: string) => {
+ if (!what3words.trim()) {
+ showToast('warning', t('calls.what3words_required'));
+ return;
+ }
+
+ // Three words separated by dots
+ if (!/^[a-z]+\.[a-z]+\.[a-z]+$/.test(what3words.trim().toLowerCase())) {
+ showToast('warning', t('calls.what3words_invalid_format'));
+ return;
+ }
+
+ safeTrack('call_edit_what3words_search_attempted', { timestamp: new Date().toISOString(), callId: callId || '' });
+
+ setIsGeocodingWhat3Words(true);
+ try {
+ const apiKey = config?.W3WKey;
+
+ if (!apiKey) {
+ throw new Error('what3words API key not configured');
+ }
+
+ const response = await axios.get(`https://api.what3words.com/v3/convert-to-coordinates?words=${encodeURIComponent(what3words)}&key=${apiKey}`);
+
+ if (response.data.coordinates) {
+ handleLocationSelected({
+ latitude: response.data.coordinates.lat,
+ longitude: response.data.coordinates.lng,
+ ...(response.data.nearestPlace ? { address: response.data.nearestPlace } : {}),
+ });
+ showToast('success', t('calls.what3words_found'));
+ } else {
+ showToast('error', t('calls.what3words_not_found'));
+ }
+ } catch (error) {
+ logger.error({ message: 'Error geocoding what3words', context: { callId, error } });
+ showToast('error', t('calls.what3words_geocoding_error'));
+ } finally {
+ setIsGeocodingWhat3Words(false);
+ }
+ };
+
+ const handlePlusCodeSearch = async (plusCode: string) => {
+ if (!plusCode.trim()) {
+ showToast('warning', t('calls.plus_code_required'));
+ return;
+ }
+
+ safeTrack('call_edit_plus_code_search_attempted', { timestamp: new Date().toISOString(), callId: callId || '' });
+
+ setIsGeocodingPlusCode(true);
+ try {
+ const apiKey = config?.GoogleMapsKey;
+
+ if (!apiKey) {
+ throw new Error('Google Maps API key not configured');
+ }
+
+ const response = await axios.get(`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(plusCode)}&key=${apiKey}`);
+ const result = response.data.status === 'OK' ? response.data.results[0] : undefined;
+
+ if (result) {
+ handleLocationSelected({
+ latitude: result.geometry.location.lat,
+ longitude: result.geometry.location.lng,
+ address: result.formatted_address,
+ });
+ showToast('success', t('calls.plus_code_found'));
+ } else {
+ showToast('error', t('calls.plus_code_not_found'));
+ }
+ } catch (error) {
+ logger.error({ message: 'Error geocoding plus code', context: { callId, error } });
+ showToast('error', t('calls.plus_code_geocoding_error'));
+ } finally {
+ setIsGeocodingPlusCode(false);
+ }
+ };
+
// The calls store's isLoading/error are shared with the background call-list fetches that run on
// app resume and active-call selection. Gate only on what this form actually needs -- the call
// itself -- so a background refetch (or a failure in one) can never replace a half-edited form.
@@ -784,6 +1042,15 @@ export default function EditCall() {
);
}
+ // Same field set and rules as the new-call screen. The location card groups four of them, so it only
+ // disappears once the policy has hidden all four; the map fills in the geolocation, so it follows that
+ // rule.
+ const showAddress = fieldPolicy.isVisible(NewCallFieldKeys.Address);
+ const showGeolocation = fieldPolicy.isVisible(NewCallFieldKeys.Geolocation);
+ const showWhat3Words = fieldPolicy.isVisible(NewCallFieldKeys.What3Words);
+ const showPlusCode = fieldPolicy.isVisible(NewCallFieldKeys.PlusCode);
+ const showLocationCard = showAddress || showGeolocation || showWhat3Words || showPlusCode;
+
return (
<>
-
-
-
- {t('calls.destination')}
-
- {
- const selectedDestinationLabel = value === NO_DESTINATION_POI_VALUE ? t('common.none') : destinationPoiOptions.find((option) => option.value === value)?.label;
-
- return (
-
- );
- }}
- />
-
-
+ {fieldPolicy.isVisible(NewCallFieldKeys.DestinationPoi) ? (
+
+
+
+ {t('calls.destination')}
+
+ {
+ const selectedDestinationLabel = value === NO_DESTINATION_POI_VALUE ? t('common.none') : destinationPoiOptions.find((option) => option.value === value)?.label;
+
+ return (
+
+ );
+ }}
+ />
+
+
+ ) : null}
+
+ {fieldPolicy.isVisible(NewCallFieldKeys.Note) ? (
+
+
+
+ {t('calls.note')}
+
+ (
+
+ )}
+ />
+
+
+ ) : null}
+
+ {showLocationCard ? (
+
+ {t('calls.call_location')}
+
+ {/* Address Field */}
+ {showAddress ? (
+
+
+ {t('calls.address')}
+
+ (
+
+
+
+
+
+
+
+
+ )}
+ />
+
+ ) : null}
-
-
-
- {t('calls.note')}
-
- (
-
- )}
- />
-
-
+ {/* GPS Coordinates Field */}
+ {showGeolocation ? (
+
+
+ {t('calls.coordinates')}
+
+ (
+
+
+
+
+
+
+
+
+ )}
+ />
+
+ ) : null}
-
- {t('calls.call_location')}
+ {/* what3words Field */}
+ {showWhat3Words ? (
+
+
+ {t('calls.what3words')}
+
+ (
+
+
+
+
+
+
+
+
+ )}
+ />
+
+ ) : null}
- {/* Address Field */}
-
-
- {t('calls.address')}
-
- (
-
-
+ {/* Plus Code Field */}
+ {showPlusCode ? (
+
+
+ {t('calls.plus_code')}
+
+ (
+
+
+
+
+
+
+
+
+ )}
+ />
+
+ ) : null}
+
+ {/* Map Preview: the map is how the geolocation gets filled in, so it follows that rule. */}
+ {showGeolocation ? (
+
+ {selectedLocation ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
+
+ ) : null}
+
+ {fieldPolicy.isVisible(NewCallFieldKeys.ContactName) ? (
+
+
+
+ {t('calls.contact_name')}
+
+ (
+
+
+
+ )}
+ />
+
+
+ ) : null}
+
+ {fieldPolicy.isVisible(NewCallFieldKeys.ContactInfo) ? (
+
+
+
+ {t('calls.contact_info')}
+
+ (
+
+
+
+ )}
+ />
+
+
+ ) : null}
+
+ {/* Call identifiers: one card for the three, shown while any of them is. */}
+ {fieldPolicy.isVisible(NewCallFieldKeys.ExternalId) || fieldPolicy.isVisible(NewCallFieldKeys.IncidentId) || fieldPolicy.isVisible(NewCallFieldKeys.ReferenceId) ? (
+
+ {fieldPolicy.isVisible(NewCallFieldKeys.ExternalId) ? (
+
+
+ {t('calls.external_id')}
+
+ (
-
+
-
-
-
- )}
- />
-
-
- {/* Map Preview */}
-
- {selectedLocation ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
- {t('calls.contact_name')}
-
- (
-
-
-
- )}
- />
-
-
-
-
-
-
- {t('calls.contact_info')}
-
- (
-
-
-
- )}
- />
-
-
-
-
- {t('calls.dispatch_to')}
-
-
+ )}
+ />
+
+ ) : null}
+ {fieldPolicy.isVisible(NewCallFieldKeys.IncidentId) ? (
+
+
+ {t('calls.incident_id')}
+
+ (
+
+
+
+ )}
+ />
+
+ ) : null}
+ {fieldPolicy.isVisible(NewCallFieldKeys.ReferenceId) ? (
+
+
+ {t('calls.reference_id')}
+
+ (
+
+
+
+ )}
+ />
+
+ ) : null}
+
+ ) : null}
+
+ {fieldPolicy.isVisible(NewCallFieldKeys.DispatchOn) ? (
+
+
+
+ {t('calls.dispatch_on')}
+
+ (
+
+ )}
+ />
+
+
+ ) : null}
+
+ {fieldPolicy.isVisible(NewCallFieldKeys.DispatchList) ? (
+
+
+ {t('calls.dispatch_to')}
+ {fieldPolicy.isRequired(NewCallFieldKeys.DispatchList) ? ' *' : null}
+
+
+
+ ) : null}
-
+ ) : null}
+
+ ) : null}
{fieldPolicy.isVisible(NewCallFieldKeys.ContactName) ? (
-
+
{t('calls.contact_name')}
@@ -1249,7 +1298,7 @@ export default function NewCall() {
{fieldPolicy.isVisible(NewCallFieldKeys.ContactInfo) ? (
-
+
{t('calls.contact_info')}
@@ -1266,12 +1315,86 @@ export default function NewCall() {
) : null}
-
- {t('calls.dispatch_to')}
- setShowDispatchModal(true)} className="w-full">
- {getDispatchSummary()}
-
-
+ {/* Call identifiers: one card for the three, shown while any of them is. */}
+ {fieldPolicy.isVisible(NewCallFieldKeys.ExternalId) || fieldPolicy.isVisible(NewCallFieldKeys.IncidentId) || fieldPolicy.isVisible(NewCallFieldKeys.ReferenceId) ? (
+
+ {fieldPolicy.isVisible(NewCallFieldKeys.ExternalId) ? (
+
+
+ {t('calls.external_id')}
+
+ (
+
+
+
+ )}
+ />
+
+ ) : null}
+ {fieldPolicy.isVisible(NewCallFieldKeys.IncidentId) ? (
+
+
+ {t('calls.incident_id')}
+
+ (
+
+
+
+ )}
+ />
+
+ ) : null}
+ {fieldPolicy.isVisible(NewCallFieldKeys.ReferenceId) ? (
+
+
+ {t('calls.reference_id')}
+
+ (
+
+
+
+ )}
+ />
+
+ ) : null}
+
+ ) : null}
+
+ {fieldPolicy.isVisible(NewCallFieldKeys.DispatchOn) ? (
+
+
+
+ {t('calls.dispatch_on')}
+
+ }
+ />
+
+
+ ) : null}
+
+ {fieldPolicy.isVisible(NewCallFieldKeys.DispatchList) ? (
+
+
+ {t('calls.dispatch_to')}
+ {fieldPolicy.isRequired(NewCallFieldKeys.DispatchList) ? ' *' : null}
+
+ setShowDispatchModal(true)} className="w-full">
+ {getDispatchSummary()}
+
+
+ ) : null}
router.back()}>
diff --git a/src/components/operations/scope-picker.tsx b/src/components/operations/scope-picker.tsx
index 7345ef2..cb382fa 100644
--- a/src/components/operations/scope-picker.tsx
+++ b/src/components/operations/scope-picker.tsx
@@ -7,7 +7,7 @@ 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 { dateOf, documentNumberLabel, reportNumberText, reportScope, scopeKey, scopeName, sumHours, type TimeScope } from '@/lib/operations/time';
import type { Deployment, TimeReport } from '@/models/v4/operations';
export const useScopeLabel = () => {
@@ -63,7 +63,7 @@ export const DayReports = ({ deployment, reports, openId, onOpen }: DayReportsPr
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))}
+ {documentNumberLabel(reportNumberText(report))} · {label(deployment, reportScope(report))}
{t(`operations.reportStatus.${report.Status}`)} · {t('operations.time.hours', { hours: sumHours(report.Entries) })}
@@ -93,7 +93,7 @@ export const AwaitingApproval = ({ deployment, reports, onOpen }: AwaitingApprov
{reports.map((report) => (
onOpen(report)} testID={`operations-awaiting-${report.Id}`}>
- {dateOf(report.ReportDate)} · #{report.ReportNumber} · {label(deployment, reportScope(report))}
+ {dateOf(report.ReportDate)} · {documentNumberLabel(reportNumberText(report))} · {label(deployment, reportScope(report))}
))}
diff --git a/src/components/operations/time-report-editor.tsx b/src/components/operations/time-report-editor.tsx
index a20c0cb..1497ef7 100644
--- a/src/components/operations/time-report-editor.tsx
+++ b/src/components/operations/time-report-editor.tsx
@@ -10,7 +10,7 @@ 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 { applyToCrew, combineDateTime, dateOf, endLocalFor, entryHours, hoursBySubject, isReportEditable, newEntry, subjectIdOf, sumHours, timeOf } from '@/lib/operations/time';
+import { applyToCrew, combineDateTime, dateOf, endLocalFor, entryHours, hoursBySubject, isReportEditable, newEntry, reportNumberText, subjectIdOf, sumHours, timeOf } from '@/lib/operations/time';
import type { TimeEntry, TimeReport, TimeReportIssue } from '@/models/v4/operations';
import { TimeEntryType, TimeReportScope, TimeReportStatus, TimeSubjectType } from '@/models/v4/operations';
@@ -105,16 +105,16 @@ export const TimeReportEditor = ({
const title = !report
? ''
: report.Scope === TimeReportScope.Crew
- ? t('operations.time.crewReport', { number: report.ReportNumber, name: scopeLabel })
+ ? t('operations.time.crewReport', { number: reportNumberText(report), name: scopeLabel })
: report.Scope === TimeReportScope.Individual
- ? t('operations.time.individualReport', { number: report.ReportNumber, name: scopeLabel })
- : t('operations.time.report', { number: report.ReportNumber });
+ ? t('operations.time.individualReport', { number: reportNumberText(report), name: scopeLabel })
+ : t('operations.time.report', { number: reportNumberText(report) });
if (!report) {
return (
{isCrew ? t('operations.time.noCrewReport', { name: scopeLabel }) : t('operations.time.noReport')}
- {coveredBy ? {t('operations.time.coveredBy', { number: coveredBy.ReportNumber })} : null}
+ {coveredBy ? {t('operations.time.coveredBy', { number: reportNumberText(coveredBy) })} : null}
{canCreate && !coveredBy ? (
{isCrew ? t('operations.time.startCrew') : t('operations.time.start')}
diff --git a/src/lib/__tests__/call-field-policy.test.ts b/src/lib/__tests__/call-field-policy.test.ts
new file mode 100644
index 0000000..44fd3be
--- /dev/null
+++ b/src/lib/__tests__/call-field-policy.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, it } from '@jest/globals';
+import { type TFunction } from 'i18next';
+
+import { NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData';
+
+import {
+ CALL_FIELD_LABEL_KEYS,
+ CALL_FORM_UNCOLLECTED_FIELD_KEYS,
+ formatCallFieldLabels,
+ formatCallGeolocation,
+ getMissingCallFieldsFromError,
+ getScheduledDispatchPrefill,
+ isPendingCallState,
+ isScheduledDispatchTooSoon,
+ MIN_SCHEDULED_DISPATCH_LEAD_MINUTES,
+ parseUtcTimestamp,
+ toDispatchOnUtc,
+} from '../call-field-policy';
+
+const t = ((key: string) => `t(${key})`) as unknown as TFunction;
+
+const badRequest = (data: unknown) => ({ isAxiosError: true, response: { status: 400, data } });
+
+describe('call field policy helpers', () => {
+ describe('CALL_FIELD_LABEL_KEYS', () => {
+ // The server names any of these keys when it rejects a save, so every one needs a label.
+ it('should have a label for every policy key', () => {
+ for (const key of Object.values(NewCallFieldKeys)) {
+ expect(CALL_FIELD_LABEL_KEYS[key]).toMatch(/^calls\./);
+ }
+ });
+ });
+
+ describe('formatCallFieldLabels', () => {
+ it('should join the translated labels in order', () => {
+ expect(formatCallFieldLabels([NewCallFieldKeys.ContactName, NewCallFieldKeys.DispatchList], t)).toBe('t(calls.contact_name), t(calls.dispatch_to)');
+ });
+
+ it('should match keys regardless of casing', () => {
+ expect(formatCallFieldLabels(['CONTACTINFO', 'externalid'], t)).toBe('t(calls.contact_info), t(calls.external_id)');
+ });
+
+ it('should fall back to the raw key for a key this app does not know', () => {
+ expect(formatCallFieldLabels(['note', 'somethingNew'], t)).toBe('t(calls.note), somethingNew');
+ });
+ });
+
+ describe('CALL_FORM_UNCOLLECTED_FIELD_KEYS', () => {
+ it('should list only the fields the call forms have no input for', () => {
+ expect([...CALL_FORM_UNCOLLECTED_FIELD_KEYS].sort()).toEqual(
+ [NewCallFieldKeys.IndoorLocation, NewCallFieldKeys.LinkedCall, NewCallFieldKeys.Protocols].sort()
+ );
+ });
+ });
+
+ describe('formatCallGeolocation', () => {
+ it('should format a usable point', () => {
+ expect(formatCallGeolocation(39.7392, -104.9903)).toBe('39.7392,-104.9903');
+ });
+
+ // The server's rule: one zero coordinate is a place, both zero is a client with no fix.
+ it('should keep a point on the equator or the prime meridian', () => {
+ expect(formatCallGeolocation(0, 32.5)).toBe('0,32.5');
+ expect(formatCallGeolocation(51.4779, 0)).toBe('51.4779,0');
+ });
+
+ it('should treat a missing, 0,0 or off-globe point as no location', () => {
+ expect(formatCallGeolocation(undefined, undefined)).toBe('');
+ expect(formatCallGeolocation(10, null)).toBe('');
+ expect(formatCallGeolocation(Number.NaN, 10)).toBe('');
+ expect(formatCallGeolocation(0, 0)).toBe('');
+ expect(formatCallGeolocation(91, 10)).toBe('');
+ expect(formatCallGeolocation(10, -181)).toBe('');
+ });
+ });
+
+ describe('scheduled dispatch', () => {
+ const now = new Date('2026-10-08T12:00:00.000Z');
+
+ it('should read a dispatch time as UTC whether or not it carries a zone', () => {
+ expect(parseUtcTimestamp('2026-10-09T14:30:00')?.toISOString()).toBe('2026-10-09T14:30:00.000Z');
+ expect(parseUtcTimestamp('2026-10-09T14:30:00Z')?.toISOString()).toBe('2026-10-09T14:30:00.000Z');
+ expect(parseUtcTimestamp('2026-10-09T16:30:00+02:00')?.toISOString()).toBe('2026-10-09T14:30:00.000Z');
+ expect(parseUtcTimestamp('')).toBeNull();
+ expect(parseUtcTimestamp(undefined)).toBeNull();
+ expect(parseUtcTimestamp('not a date')).toBeNull();
+ });
+
+ it('should pre-fill the edit input only while the stored dispatch time is still ahead', () => {
+ // Scheduled and not sent yet.
+ expect(getScheduledDispatchPrefill('2026-10-08T12:05:00', now)).toBe('2026-10-08T12:05:00.000Z');
+ // Already sent: the dispatch time is history, not a schedule.
+ expect(getScheduledDispatchPrefill('2026-10-08T11:55:00', now)).toBe('');
+ expect(getScheduledDispatchPrefill('2026-10-08T12:00:00Z', now)).toBe('');
+ expect(getScheduledDispatchPrefill('', now)).toBe('');
+ expect(getScheduledDispatchPrefill(null, now)).toBe('');
+ });
+
+ it('should require a chosen time at least the lead time ahead', () => {
+ expect(MIN_SCHEDULED_DISPATCH_LEAD_MINUTES).toBe(15);
+ expect(isScheduledDispatchTooSoon('2026-10-08T12:15:00.000Z', now)).toBe(false);
+ expect(isScheduledDispatchTooSoon('2026-10-09T08:00:00.000Z', now)).toBe(false);
+ expect(isScheduledDispatchTooSoon('2026-10-08T12:14:59.000Z', now)).toBe(true);
+ expect(isScheduledDispatchTooSoon('2026-10-08T11:00:00.000Z', now)).toBe(true);
+ expect(isScheduledDispatchTooSoon('garbage', now)).toBe(true);
+ });
+
+ it('should send a chosen time as ISO 8601 UTC, and nothing for an empty input', () => {
+ expect(toDispatchOnUtc('2026-10-09T14:30:00.000Z')).toBe('2026-10-09T14:30:00.000Z');
+ expect(toDispatchOnUtc('2026-10-09T16:30:00+02:00')).toBe('2026-10-09T14:30:00.000Z');
+ expect(toDispatchOnUtc('')).toBeUndefined();
+ expect(toDispatchOnUtc(undefined)).toBeUndefined();
+ });
+ });
+
+ describe('isPendingCallState', () => {
+ it('should recognise the pending state as a number, a numeric string or a name', () => {
+ expect(isPendingCallState(8)).toBe(true);
+ expect(isPendingCallState('8')).toBe(true);
+ expect(isPendingCallState(' Pending ')).toBe(true);
+ });
+
+ it('should not treat other states as pending', () => {
+ expect(isPendingCallState(0)).toBe(false);
+ expect(isPendingCallState('0')).toBe(false);
+ expect(isPendingCallState('Active')).toBe(false);
+ expect(isPendingCallState(undefined)).toBe(false);
+ expect(isPendingCallState(null)).toBe(false);
+ });
+ });
+
+ describe('getMissingCallFieldsFromError', () => {
+ it('should read the keys from a plain-text rejection', () => {
+ expect(getMissingCallFieldsFromError(badRequest('Required call fields are missing: contactName, externalId'))).toEqual([NewCallFieldKeys.ContactName, NewCallFieldKeys.ExternalId]);
+ });
+
+ it('should canonicalise key casing and keep unknown keys', () => {
+ expect(getMissingCallFieldsFromError(badRequest('Required call fields are missing: CONTACTINFO, futureField'))).toEqual([NewCallFieldKeys.ContactInfo, 'futureField']);
+ });
+
+ it('should read the message from an object body', () => {
+ expect(getMissingCallFieldsFromError(badRequest({ Message: 'Required call fields are missing: dispatchOn' }))).toEqual([NewCallFieldKeys.DispatchOn]);
+ });
+
+ it('should return null for any other failure', () => {
+ expect(getMissingCallFieldsFromError(badRequest('Bad destination'))).toBeNull();
+ expect(getMissingCallFieldsFromError(badRequest(''))).toBeNull();
+ expect(getMissingCallFieldsFromError({ response: { status: 500, data: 'Required call fields are missing: note' } })).toBeNull();
+ expect(getMissingCallFieldsFromError(new Error('network down'))).toBeNull();
+ expect(getMissingCallFieldsFromError(undefined)).toBeNull();
+ });
+ });
+});
diff --git a/src/lib/call-field-policy.ts b/src/lib/call-field-policy.ts
new file mode 100644
index 0000000..d01ed23
--- /dev/null
+++ b/src/lib/call-field-policy.ts
@@ -0,0 +1,190 @@
+import { type TFunction } from 'i18next';
+
+import { type NewCallFieldKey, NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData';
+
+/**
+ * Shared pieces of the department's call field policy (Department > Dispatch Settings > New Call Form
+ * Fields) used by both the new-call and edit-call screens.
+ */
+
+/**
+ * The label each policy key is shown under. The policy speaks in stable wire keys; a dispatcher told to
+ * fill in 'contactName' is being shown the protocol rather than their own form, so every key maps back
+ * to the label the call forms put on the field. Keys neither screen renders (external id, protocols...)
+ * still get a label, because the server names them when it rejects a save.
+ */
+export const CALL_FIELD_LABEL_KEYS: Record = {
+ [NewCallFieldKeys.Address]: 'calls.address',
+ [NewCallFieldKeys.Geolocation]: 'calls.coordinates',
+ [NewCallFieldKeys.What3Words]: 'calls.what3words',
+ [NewCallFieldKeys.PlusCode]: 'calls.plus_code',
+ [NewCallFieldKeys.DestinationPoi]: 'calls.destination',
+ [NewCallFieldKeys.IndoorLocation]: 'calls.indoor_location',
+ [NewCallFieldKeys.Note]: 'calls.note',
+ [NewCallFieldKeys.ContactName]: 'calls.contact_name',
+ [NewCallFieldKeys.ContactInfo]: 'calls.contact_info',
+ [NewCallFieldKeys.ExternalId]: 'calls.external_id',
+ [NewCallFieldKeys.IncidentId]: 'calls.incident_id',
+ [NewCallFieldKeys.ReferenceId]: 'calls.reference_id',
+ [NewCallFieldKeys.Protocols]: 'calls.protocols',
+ [NewCallFieldKeys.LinkedCall]: 'calls.linked_call',
+ [NewCallFieldKeys.DispatchOn]: 'calls.dispatch_on',
+ [NewCallFieldKeys.DispatchList]: 'calls.dispatch_to',
+};
+
+/** Lowercased key -> canonical key, so a server message's casing never decides whether a label is found. */
+const CANONICAL_KEYS = new Map(Object.values(NewCallFieldKeys).map((key) => [key.toLowerCase(), key]));
+
+/**
+ * The comma-separated labels for a list of policy keys, ready for `calls.required_fields_missing`. A key
+ * this app does not know (a newer server) falls back to the raw key, which at least names something,
+ * rather than being dropped from the message.
+ */
+export const formatCallFieldLabels = (keys: readonly string[], t: TFunction): string =>
+ keys
+ .map((key) => {
+ const canonical = CANONICAL_KEYS.get(key.toLowerCase());
+
+ return canonical ? t(CALL_FIELD_LABEL_KEYS[canonical]) : key;
+ })
+ .join(', ');
+
+/**
+ * Policy keys this app's call forms have no input for. The client check skips them -- requiring a field
+ * the dispatcher has no way to fill only produces a message they cannot act on -- and leaves them to the
+ * server:
+ * protocols, linkedCall and indoorLocation are only enforced when the request carries ProtocolIds,
+ * LinkedCallId or IndoorMapZoneId, which this app never sends, so the server does not hold it to them.
+ */
+export const CALL_FORM_UNCOLLECTED_FIELD_KEYS: ReadonlySet = new Set([NewCallFieldKeys.IndoorLocation, NewCallFieldKeys.Protocols, NewCallFieldKeys.LinkedCall]);
+
+/** How far ahead a scheduled dispatch must be, matching the web call form. */
+export const MIN_SCHEDULED_DISPATCH_LEAD_MINUTES = 15;
+
+/**
+ * Parses a dispatch time from the API. The stored value is UTC, but older servers send it without a zone
+ * designator, which `Date` would otherwise read as local time.
+ */
+export const parseUtcTimestamp = (value: string | null | undefined): Date | null => {
+ const trimmed = value?.trim();
+
+ if (!trimmed) {
+ return null;
+ }
+
+ const parsed = new Date(/(?:Z|[+-]\d{2}:?\d{2})$/i.test(trimmed) ? trimmed : `${trimmed}Z`);
+
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+};
+
+/**
+ * The value the edit form's scheduled-dispatch input starts with: the stored dispatch time (the call result's
+ * DispatchedOnUtc) while it is still ahead -- the call is scheduled and not sent yet -- and '' otherwise.
+ */
+export const getScheduledDispatchPrefill = (dispatchedOnUtc: string | null | undefined, now: Date = new Date()): string => {
+ const dispatchOn = parseUtcTimestamp(dispatchedOnUtc);
+
+ return dispatchOn && dispatchOn.getTime() > now.getTime() ? dispatchOn.toISOString() : '';
+};
+
+/** True when a chosen dispatch time is not at least MIN_SCHEDULED_DISPATCH_LEAD_MINUTES ahead (or unreadable). */
+export const isScheduledDispatchTooSoon = (value: string, now: Date = new Date()): boolean => {
+ const dispatchOn = parseUtcTimestamp(value);
+
+ return !dispatchOn || dispatchOn.getTime() < now.getTime() + MIN_SCHEDULED_DISPATCH_LEAD_MINUTES * 60 * 1000;
+};
+
+/** The `DispatchOnUtc` to send for a chosen time (ISO 8601 UTC), or undefined to leave it out. */
+export const toDispatchOnUtc = (value: string | null | undefined): string | undefined => parseUtcTimestamp(value)?.toISOString();
+
+/**
+ * The `Geolocation` value a call is saved with, or '' when the form holds no usable point. Mirrors the
+ * server's rule, so "required" means the same thing on both sides: a single zero coordinate (the equator,
+ * the prime meridian) is a real place, but a missing coordinate, 0,0 or an off-globe value is not.
+ */
+export const formatCallGeolocation = (latitude?: number | null, longitude?: number | null): string => {
+ if (typeof latitude !== 'number' || typeof longitude !== 'number' || !Number.isFinite(latitude) || !Number.isFinite(longitude)) {
+ return '';
+ }
+
+ if (latitude === 0 && longitude === 0) {
+ return '';
+ }
+
+ if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
+ return '';
+ }
+
+ return `${latitude},${longitude}`;
+};
+
+const CALL_STATE_PENDING = 8;
+
+/**
+ * Whether a call is Pending (State 8): saved and numbered but not dispatched yet. Core sends the state as a
+ * number; older API versions send its name.
+ */
+export const isPendingCallState = (state: number | string | null | undefined): boolean => {
+ if (state === null || state === undefined) {
+ return false;
+ }
+
+ if (typeof state === 'number') {
+ return state === CALL_STATE_PENDING;
+ }
+
+ const normalized = state.trim().toLowerCase();
+
+ return normalized === String(CALL_STATE_PENDING) || normalized === 'pending';
+};
+
+const REQUIRED_FIELDS_MESSAGE = /required call fields are missing:\s*(.+)$/i;
+
+const readErrorText = (error: unknown): string | null => {
+ const data = (error as { response?: { data?: unknown } } | null | undefined)?.response?.data;
+
+ if (typeof data === 'string') {
+ return data;
+ }
+
+ if (data && typeof data === 'object') {
+ for (const field of ['Message', 'message', 'detail', 'title']) {
+ const value = (data as Record)[field];
+
+ if (typeof value === 'string') {
+ return value;
+ }
+ }
+ }
+
+ return null;
+};
+
+/**
+ * The policy keys a save was rejected for, when the server refused it because required call fields were
+ * missing (HTTP 400, "Required call fields are missing: key1, key2"); null for any other failure. Lets the
+ * screen name the fields instead of showing a generic error -- including fields this app has no input for,
+ * which it cannot check before sending.
+ */
+export const getMissingCallFieldsFromError = (error: unknown): string[] | null => {
+ const status = (error as { response?: { status?: unknown } } | null | undefined)?.response?.status;
+
+ if (status !== 400) {
+ return null;
+ }
+
+ const text = readErrorText(error);
+ const match = text ? REQUIRED_FIELDS_MESSAGE.exec(text.trim()) : null;
+
+ if (!match?.[1]) {
+ return null;
+ }
+
+ const keys = match[1]
+ .split(',')
+ .map((key) => key.trim().replace(/[."]+$/, ''))
+ .filter((key) => key.length > 0)
+ .map((key) => CANONICAL_KEYS.get(key.toLowerCase()) ?? key);
+
+ return keys.length > 0 ? keys : null;
+};
diff --git a/src/lib/operations/__tests__/time.test.ts b/src/lib/operations/__tests__/time.test.ts
index a980db5..c168f0b 100644
--- a/src/lib/operations/__tests__/time.test.ts
+++ b/src/lib/operations/__tests__/time.test.ts
@@ -4,6 +4,7 @@ import {
combineDateTime,
coveringReport,
defaultScope,
+ documentNumberLabel,
endLocalFor,
entryHours,
hoursBySubject,
@@ -12,6 +13,7 @@ import {
newEntry,
operationsError,
reportForScope,
+ reportNumberText,
scopeKey,
scopeSubjects,
shiftDateKey,
@@ -139,3 +141,16 @@ it('maps server failures to short reason codes', () => {
expect(operationsError({ response: { status: 200, data: { type: 'protected_data_required' } } })).toBe('locked');
expect(operationsError(new Error('boom'))).toBe('retry');
});
+
+it('shows a time report by its number as issued, falling back to the plain number', () => {
+ expect(reportNumberText(report({ ReportNumber: 3, DisplayNumber: 'DTR-2026-0003' }))).toBe('DTR-2026-0003');
+ expect(reportNumberText(report({ ReportNumber: 3, DisplayNumber: '3' }))).toBe('3');
+ expect(reportNumberText(report({ ReportNumber: 3 }))).toBe('3');
+ expect(reportNumberText(report({ ReportNumber: 3, DisplayNumber: '' }))).toBe('3');
+});
+
+it('writes a plain document number with # and any other number as issued', () => {
+ expect(documentNumberLabel('3')).toBe('#3');
+ expect(documentNumberLabel('00042')).toBe('#00042');
+ expect(documentNumberLabel('DTR-2026-0003')).toBe('DTR-2026-0003');
+});
diff --git a/src/lib/operations/time.ts b/src/lib/operations/time.ts
index b9085e0..90983fa 100644
--- a/src/lib/operations/time.ts
+++ b/src/lib/operations/time.ts
@@ -230,6 +230,12 @@ export const hoursBySubject = (entries: TimeEntry[]) =>
return totals;
}, {});
+/** A time report's number as issued (the department's numbering pattern); a server without DisplayNumber sends only the plain number. */
+export const reportNumberText = (report: Pick) => report.DisplayNumber || String(report.ReportNumber);
+
+/** How a document number reads on its own: "#3" for a plain number, as reports always showed, and the issued text otherwise ("DTR-2026-0003"). */
+export const documentNumberLabel = (number: string) => (/^\d+$/.test(number) ? `#${number}` : number);
+
export const isReportEditable = (report: TimeReport | null) => !!report && report.Status === TimeReportStatus.Draft && report.CanAct;
export const operationsError = (error: unknown): string => {
diff --git a/src/models/v4/operations/index.ts b/src/models/v4/operations/index.ts
index 0ca658a..d971628 100644
--- a/src/models/v4/operations/index.ts
+++ b/src/models/v4/operations/index.ts
@@ -117,6 +117,8 @@ export interface TimeReport {
Id: string;
DeploymentId: string;
ReportNumber: number;
+ /** The number as issued: the department's numbering pattern, or ReportNumber as text when it keeps the built-in numbers. Older servers leave it out. */
+ DisplayNumber?: string | null;
ReportDate: string;
/** TimeReportScope value. */
Scope: number;
diff --git a/src/translations/ar.json b/src/translations/ar.json
index 62d48d9..6081076 100644
--- a/src/translations/ar.json
+++ b/src/translations/ar.json
@@ -325,11 +325,14 @@
"destination_load_error": "تعذر تحميل نقاط الاهتمام الخاصة بالوجهة",
"destination_placeholder": "اختر وجهة",
"directions": "الاتجاهات",
+ "dispatch_on": "وقت الإرسال المجدول",
+ "dispatch_on_too_soon": "يجب أن يكون وقت الإرسال المجدول بعد {{minutes}} دقيقة على الأقل من الآن",
"dispatch_to": "إرسال إلى",
"dispatch_to_everyone": "إرسال إلى جميع الموظفين المتاحين",
"edit_call": "تعديل المكالمة",
"edit_call_description": "تحديث معلومات المكالمة",
"everyone": "الجميع",
+ "external_id": "المعرف الخارجي",
"field_policy_loading": "لا يزال يتم تحميل إعدادات البلاغات في قسمك، يرجى المحاولة مرة أخرى بعد لحظات",
"files": {
"no_files": "لا توجد ملفات متاحة",
@@ -338,6 +341,9 @@
},
"geocoding_error": "فشل البحث عن العنوان، يرجى المحاولة مرة أخرى",
"groups": "المجموعات",
+ "incident_id": "معرف الحادث",
+ "indoor_location": "الموقع الداخلي",
+ "linked_call": "البلاغ المرتبط",
"loading": "جاري تحميل المكالمات...",
"loading_calls": "جاري تحميل المكالمات...",
"name": "الاسم",
@@ -366,7 +372,9 @@
"plus_code_required": "يرجى إدخال رمز Plus للبحث",
"priority": "الأولوية",
"priority_placeholder": "اختر أولوية المكالمة",
- "required_fields_missing": "يطلب قسمك هذه الحقول قبل إنشاء البلاغ: {{fields}}",
+ "protocols": "البروتوكولات",
+ "reference_id": "معرف المرجع",
+ "required_fields_missing": "يطلب قسمك هذه الحقول قبل حفظ البلاغ: {{fields}}",
"roles": "الأدوار",
"search": "البحث في المكالمات...",
"search_address": "البحث عن العنوان",
diff --git a/src/translations/de.json b/src/translations/de.json
index 8607f8d..c4830cd 100644
--- a/src/translations/de.json
+++ b/src/translations/de.json
@@ -325,11 +325,14 @@
"destination_load_error": "Ziel-POIs konnten nicht geladen werden",
"destination_placeholder": "Ziel auswählen",
"directions": "Wegbeschreibung",
+ "dispatch_on": "Geplante Alarmierungszeit",
+ "dispatch_on_too_soon": "Die geplante Alarmierungszeit muss mindestens {{minutes}} Minuten in der Zukunft liegen",
"dispatch_to": "Alarmieren an",
"dispatch_to_everyone": "An alle verfügbaren Kräfte alarmieren",
"edit_call": "Einsatz bearbeiten",
"edit_call_description": "Einsatzinformationen aktualisieren",
"everyone": "Alle",
+ "external_id": "Externe ID",
"field_policy_loading": "Die Einsatzeinstellungen Ihrer Abteilung werden noch geladen, bitte versuchen Sie es gleich erneut",
"files": {
"no_files": "Keine Dateien verfügbar",
@@ -338,6 +341,9 @@
},
"geocoding_error": "Adresse konnte nicht gesucht werden, bitte versuchen Sie es erneut",
"groups": "Gruppen",
+ "incident_id": "Vorfall-ID",
+ "indoor_location": "Standort im Gebäude",
+ "linked_call": "Verknüpfter Einsatz",
"loading": "Einsätze werden geladen...",
"loading_calls": "Einsätze werden geladen...",
"name": "Name",
@@ -366,7 +372,9 @@
"plus_code_required": "Bitte geben Sie einen Plus-Code zum Suchen ein",
"priority": "Priorität",
"priority_placeholder": "Einsatzpriorität auswählen",
- "required_fields_missing": "Ihre Abteilung benötigt diese Felder, bevor ein Einsatz angelegt werden kann: {{fields}}",
+ "protocols": "Protokolle",
+ "reference_id": "Referenz-ID",
+ "required_fields_missing": "Ihre Abteilung benötigt diese Felder, bevor ein Einsatz gespeichert werden kann: {{fields}}",
"roles": "Rollen",
"search": "Einsätze suchen...",
"search_address": "Adresse suchen",
diff --git a/src/translations/el.json b/src/translations/el.json
index 741358c..15296cf 100644
--- a/src/translations/el.json
+++ b/src/translations/el.json
@@ -325,11 +325,14 @@
"destination_load_error": "Αποτυχία φόρτωσης σημείων ενδιαφέροντος προορισμού",
"destination_placeholder": "Επιλέξτε προορισμό",
"directions": "Οδηγίες",
+ "dispatch_on": "Προγραμματισμένη Ώρα Αποστολής",
+ "dispatch_on_too_soon": "Η προγραμματισμένη ώρα αποστολής πρέπει να απέχει τουλάχιστον {{minutes}} λεπτά από τώρα",
"dispatch_to": "Αποστολή Προς",
"dispatch_to_everyone": "Αποστολή σε όλο το διαθέσιμο προσωπικό",
"edit_call": "Επεξεργασία Κλήσης",
"edit_call_description": "Ενημέρωση πληροφοριών κλήσης",
"everyone": "Όλοι",
+ "external_id": "Εξωτερικό Αναγνωριστικό",
"field_policy_loading": "Οι ρυθμίσεις κλήσεων του τμήματός σας φορτώνονται ακόμη, δοκιμάστε ξανά σε λίγο",
"files": {
"no_files": "Δεν υπάρχουν διαθέσιμα αρχεία",
@@ -338,6 +341,9 @@
},
"geocoding_error": "Αποτυχία αναζήτησης διεύθυνσης, δοκιμάστε ξανά",
"groups": "Ομάδες",
+ "incident_id": "Αναγνωριστικό Συμβάντος",
+ "indoor_location": "Εσωτερική Τοποθεσία",
+ "linked_call": "Συνδεδεμένη Κλήση",
"loading": "Φόρτωση κλήσεων...",
"loading_calls": "Φόρτωση κλήσεων...",
"name": "Όνομα",
@@ -366,7 +372,9 @@
"plus_code_required": "Εισαγάγετε ένα plus code για αναζήτηση",
"priority": "Προτεραιότητα",
"priority_placeholder": "Επιλέξτε την προτεραιότητα της κλήσης",
- "required_fields_missing": "Το τμήμα σας απαιτεί αυτά τα πεδία πριν δημιουργηθεί κλήση: {{fields}}",
+ "protocols": "Πρωτόκολλα",
+ "reference_id": "Αναγνωριστικό Αναφοράς",
+ "required_fields_missing": "Το τμήμα σας απαιτεί αυτά τα πεδία πριν αποθηκευτεί μια κλήση: {{fields}}",
"roles": "Ρόλοι",
"search": "Αναζήτηση κλήσεων...",
"search_address": "Αναζήτηση διεύθυνσης",
diff --git a/src/translations/en.json b/src/translations/en.json
index 4e0f1b0..5c74e94 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -325,11 +325,14 @@
"destination_load_error": "Failed to load destination POIs",
"destination_placeholder": "Select destination",
"directions": "Directions",
+ "dispatch_on": "Scheduled Dispatch Time",
+ "dispatch_on_too_soon": "The scheduled dispatch time must be at least {{minutes}} minutes from now",
"dispatch_to": "Dispatch To",
"dispatch_to_everyone": "Dispatch to all available personnel",
"edit_call": "Edit Call",
"edit_call_description": "Update call information",
"everyone": "Everyone",
+ "external_id": "External ID",
"field_policy_loading": "Still loading your department's call settings, please try again in a moment",
"files": {
"no_files": "No files available",
@@ -338,6 +341,9 @@
},
"geocoding_error": "Failed to search for address, please try again",
"groups": "Groups",
+ "incident_id": "Incident ID",
+ "indoor_location": "Indoor Location",
+ "linked_call": "Linked Call",
"loading": "Loading calls...",
"loading_calls": "Loading calls...",
"name": "Name",
@@ -366,7 +372,9 @@
"plus_code_required": "Please enter a plus code to search",
"priority": "Priority",
"priority_placeholder": "Select the priority of the call",
- "required_fields_missing": "Your department requires these fields before a call can be created: {{fields}}",
+ "protocols": "Protocols",
+ "reference_id": "Reference ID",
+ "required_fields_missing": "Your department requires these fields before a call can be saved: {{fields}}",
"roles": "Roles",
"search": "Search calls...",
"search_address": "Search address",
diff --git a/src/translations/es.json b/src/translations/es.json
index d96975c..82ead59 100644
--- a/src/translations/es.json
+++ b/src/translations/es.json
@@ -325,11 +325,14 @@
"destination_load_error": "No se pudieron cargar los POI de destino",
"destination_placeholder": "Seleccionar destino",
"directions": "Direcciones",
+ "dispatch_on": "Hora de despacho programada",
+ "dispatch_on_too_soon": "La hora de despacho programada debe ser al menos {{minutes}} minutos a partir de ahora",
"dispatch_to": "Despachar A",
"dispatch_to_everyone": "Despachar a todo el personal disponible",
"edit_call": "Editar llamada",
"edit_call_description": "Actualizar información de la llamada",
"everyone": "Todos",
+ "external_id": "ID externo",
"field_policy_loading": "Aún se están cargando los ajustes de avisos de tu departamento, inténtalo de nuevo en un momento",
"files": {
"no_files": "No hay archivos disponibles",
@@ -338,6 +341,9 @@
},
"geocoding_error": "Error al buscar la dirección, por favor inténtelo de nuevo",
"groups": "Grupos",
+ "incident_id": "ID de incidente",
+ "indoor_location": "Ubicación interior",
+ "linked_call": "Llamada vinculada",
"loading": "Cargando llamadas...",
"loading_calls": "Cargando llamadas...",
"name": "Nombre",
@@ -366,7 +372,9 @@
"plus_code_required": "Por favor ingrese un código plus para buscar",
"priority": "Prioridad",
"priority_placeholder": "Selecciona la prioridad de la llamada",
- "required_fields_missing": "Tu departamento exige estos campos antes de poder crear un aviso: {{fields}}",
+ "protocols": "Protocolos",
+ "reference_id": "ID de referencia",
+ "required_fields_missing": "Tu departamento exige estos campos antes de poder guardar un aviso: {{fields}}",
"roles": "Roles",
"search": "Buscar llamadas...",
"search_address": "Buscar dirección",
diff --git a/src/translations/fr.json b/src/translations/fr.json
index 3944031..b709624 100644
--- a/src/translations/fr.json
+++ b/src/translations/fr.json
@@ -325,11 +325,14 @@
"destination_load_error": "Impossible de charger les POI de destination",
"destination_placeholder": "Sélectionner une destination",
"directions": "Itinéraire",
+ "dispatch_on": "Heure d'envoi programmée",
+ "dispatch_on_too_soon": "L'heure d'envoi programmée doit se situer au moins {{minutes}} minutes dans le futur",
"dispatch_to": "Dépêcher vers",
"dispatch_to_everyone": "Dépêcher tout le personnel disponible",
"edit_call": "Modifier l'appel",
"edit_call_description": "Mettre à jour les informations de l'appel",
"everyone": "Tout le monde",
+ "external_id": "ID externe",
"field_policy_loading": "Les paramètres d'appel de votre département sont encore en cours de chargement, veuillez réessayer dans un instant",
"files": {
"no_files": "Aucun fichier disponible",
@@ -338,6 +341,9 @@
},
"geocoding_error": "Échec de la recherche d'adresse, veuillez réessayer",
"groups": "Groupes",
+ "incident_id": "ID d'incident",
+ "indoor_location": "Emplacement intérieur",
+ "linked_call": "Appel lié",
"loading": "Chargement des appels...",
"loading_calls": "Chargement des appels...",
"name": "Nom",
@@ -366,7 +372,9 @@
"plus_code_required": "Veuillez saisir un Plus Code à rechercher",
"priority": "Priorité",
"priority_placeholder": "Sélectionner la priorité de l'appel",
- "required_fields_missing": "Votre département exige ces champs avant qu'un appel puisse être créé : {{fields}}",
+ "protocols": "Protocoles",
+ "reference_id": "ID de référence",
+ "required_fields_missing": "Votre département exige ces champs avant qu'un appel puisse être enregistré : {{fields}}",
"roles": "Rôles",
"search": "Rechercher des appels...",
"search_address": "Rechercher l'adresse",
diff --git a/src/translations/it.json b/src/translations/it.json
index 09c2d5c..64290cc 100644
--- a/src/translations/it.json
+++ b/src/translations/it.json
@@ -325,11 +325,14 @@
"destination_load_error": "Impossibile caricare i POI di destinazione",
"destination_placeholder": "Seleziona destinazione",
"directions": "Indicazioni",
+ "dispatch_on": "Orario di invio programmato",
+ "dispatch_on_too_soon": "L'orario di invio programmato deve essere ad almeno {{minutes}} minuti da adesso",
"dispatch_to": "Invia a",
"dispatch_to_everyone": "Invia a tutto il personale disponibile",
"edit_call": "Modifica chiamata",
"edit_call_description": "Aggiorna le informazioni della chiamata",
"everyone": "Tutti",
+ "external_id": "ID esterno",
"field_policy_loading": "Le impostazioni delle chiamate del tuo dipartimento sono ancora in caricamento, riprova tra un momento",
"files": {
"no_files": "Nessun file disponibile",
@@ -338,6 +341,9 @@
},
"geocoding_error": "Impossibile cercare l'indirizzo, riprovare",
"groups": "Gruppi",
+ "incident_id": "ID incidente",
+ "indoor_location": "Posizione interna",
+ "linked_call": "Chiamata collegata",
"loading": "Caricamento chiamate...",
"loading_calls": "Caricamento chiamate...",
"name": "Nome",
@@ -366,7 +372,9 @@
"plus_code_required": "Inserire un Plus Code da cercare",
"priority": "Priorità",
"priority_placeholder": "Selezionare la priorità della chiamata",
- "required_fields_missing": "Il tuo dipartimento richiede questi campi prima di poter creare una chiamata: {{fields}}",
+ "protocols": "Protocolli",
+ "reference_id": "ID di riferimento",
+ "required_fields_missing": "Il tuo dipartimento richiede questi campi prima di poter salvare una chiamata: {{fields}}",
"roles": "Ruoli",
"search": "Cerca chiamate...",
"search_address": "Cerca indirizzo",
diff --git a/src/translations/pl.json b/src/translations/pl.json
index 1a8b10b..00cca24 100644
--- a/src/translations/pl.json
+++ b/src/translations/pl.json
@@ -325,11 +325,14 @@
"destination_load_error": "Nie udało się załadować docelowych POI",
"destination_placeholder": "Wybierz cel",
"directions": "Wskazówki dojazdu",
+ "dispatch_on": "Zaplanowany czas dysponowania",
+ "dispatch_on_too_soon": "Zaplanowany czas dysponowania musi przypadać co najmniej {{minutes}} minut od teraz",
"dispatch_to": "Zadysponuj do",
"dispatch_to_everyone": "Zadysponuj do całego dostępnego personelu",
"edit_call": "Edytuj zgłoszenie",
"edit_call_description": "Zaktualizuj informacje o zgłoszeniu",
"everyone": "Wszyscy",
+ "external_id": "Identyfikator zewnętrzny",
"field_policy_loading": "Ustawienia zgłoszeń Twojej jednostki są jeszcze wczytywane, spróbuj ponownie za chwilę",
"files": {
"no_files": "Brak dostępnych plików",
@@ -338,6 +341,9 @@
},
"geocoding_error": "Nie udało się wyszukać adresu, proszę spróbować ponownie",
"groups": "Grupy",
+ "incident_id": "Identyfikator zdarzenia",
+ "indoor_location": "Lokalizacja w budynku",
+ "linked_call": "Powiązane zgłoszenie",
"loading": "Ładowanie zgłoszeń...",
"loading_calls": "Ładowanie zgłoszeń...",
"name": "Nazwa",
@@ -366,7 +372,9 @@
"plus_code_required": "Proszę wprowadzić kod Plus do wyszukania",
"priority": "Priorytet",
"priority_placeholder": "Wybierz priorytet zgłoszenia",
- "required_fields_missing": "Twoja jednostka wymaga tych pól przed utworzeniem zgłoszenia: {{fields}}",
+ "protocols": "Protokoły",
+ "reference_id": "Identyfikator referencyjny",
+ "required_fields_missing": "Twoja jednostka wymaga tych pól przed zapisaniem zgłoszenia: {{fields}}",
"roles": "Role",
"search": "Szukaj zgłoszeń...",
"search_address": "Szukaj adresu",
diff --git a/src/translations/sv.json b/src/translations/sv.json
index 3decfe1..5eac6e0 100644
--- a/src/translations/sv.json
+++ b/src/translations/sv.json
@@ -325,11 +325,14 @@
"destination_load_error": "Det gick inte att läsa in destinations-POI:er",
"destination_placeholder": "Välj destination",
"directions": "Vägbeskrivning",
+ "dispatch_on": "Schemalagd larmtid",
+ "dispatch_on_too_soon": "Den schemalagda larmtiden måste vara minst {{minutes}} minuter fram i tiden",
"dispatch_to": "Dirigera till",
"dispatch_to_everyone": "Dirigera till all tillgänglig personal",
"edit_call": "Redigera larm",
"edit_call_description": "Uppdatera larminformation",
"everyone": "Alla",
+ "external_id": "Externt ID",
"field_policy_loading": "Din avdelnings larminställningar laddas fortfarande, försök igen om ett ögonblick",
"files": {
"no_files": "Inga filer tillgängliga",
@@ -338,6 +341,9 @@
},
"geocoding_error": "Det gick inte att söka efter adressen, försök igen",
"groups": "Grupper",
+ "incident_id": "Händelse-ID",
+ "indoor_location": "Plats inomhus",
+ "linked_call": "Länkat larm",
"loading": "Läser in larm...",
"loading_calls": "Läser in larm...",
"name": "Namn",
@@ -366,7 +372,9 @@
"plus_code_required": "Ange en plus-kod att söka efter",
"priority": "Prioritet",
"priority_placeholder": "Välj larmets prioritet",
- "required_fields_missing": "Din avdelning kräver dessa fält innan ett larm kan skapas: {{fields}}",
+ "protocols": "Protokoll",
+ "reference_id": "Referens-ID",
+ "required_fields_missing": "Din avdelning kräver dessa fält innan ett larm kan sparas: {{fields}}",
"roles": "Roller",
"search": "Sök larm...",
"search_address": "Sök adress",
diff --git a/src/translations/uk.json b/src/translations/uk.json
index 7d81dce..cf5342d 100644
--- a/src/translations/uk.json
+++ b/src/translations/uk.json
@@ -325,11 +325,14 @@
"destination_load_error": "Не вдалося завантажити POI для пункту призначення",
"destination_placeholder": "Виберіть пункт призначення",
"directions": "Маршрут",
+ "dispatch_on": "Запланований час виклику",
+ "dispatch_on_too_soon": "Запланований час виклику має бути щонайменше через {{minutes}} хвилин від цього моменту",
"dispatch_to": "Задіяти до",
"dispatch_to_everyone": "Задіяти весь доступний персонал",
"edit_call": "Редагувати виклик",
"edit_call_description": "Оновити інформацію про виклик",
"everyone": "Усі",
+ "external_id": "Зовнішній ідентифікатор",
"field_policy_loading": "Налаштування викликів вашого відділу ще завантажуються, спробуйте ще раз за мить",
"files": {
"no_files": "Файли недоступні",
@@ -338,6 +341,9 @@
},
"geocoding_error": "Не вдалося знайти адресу, будь ласка, спробуйте ще раз",
"groups": "Групи",
+ "incident_id": "Ідентифікатор інциденту",
+ "indoor_location": "Розташування в будівлі",
+ "linked_call": "Пов'язаний виклик",
"loading": "Завантаження викликів...",
"loading_calls": "Завантаження викликів...",
"name": "Назва",
@@ -366,7 +372,9 @@
"plus_code_required": "Будь ласка, введіть код Plus для пошуку",
"priority": "Пріоритет",
"priority_placeholder": "Вибрати пріоритет виклику",
- "required_fields_missing": "Ваш відділ вимагає ці поля перед створенням виклику: {{fields}}",
+ "protocols": "Протоколи",
+ "reference_id": "Ідентифікатор посилання",
+ "required_fields_missing": "Ваш відділ вимагає ці поля перед збереженням виклику: {{fields}}",
"roles": "Ролі",
"search": "Шукати виклики...",
"search_address": "Пошук адреси",