diff --git a/src/__tests__/app/call/new-archived-call.test.tsx b/src/__tests__/app/call/new-archived-call.test.tsx new file mode 100644 index 00000000..7c003602 --- /dev/null +++ b/src/__tests__/app/call/new-archived-call.test.tsx @@ -0,0 +1,230 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { createArchivedCall } from '@/api/calls/calls'; + +import NewArchivedCall from '../../../app/call/new/archived'; + +const mockToastError = jest.fn(); +const mockToastSuccess = jest.fn(); +const mockShowYear = jest.fn(); +let mockDispatchModalProps: { onConfirm: (selection: { everyone: boolean; users: string[]; groups: string[]; roles: string[]; units: string[] }) => void } | null = null; + +jest.mock('@/api/calls/calls', () => ({ createArchivedCall: jest.fn() })); +jest.mock('@/hooks/use-analytics', () => ({ useAnalytics: () => ({ trackEvent: jest.fn() }) })); +jest.mock('@/hooks/use-toast', () => ({ + useToast: () => ({ error: (...args: unknown[]) => mockToastError(...args), success: (...args: unknown[]) => mockToastSuccess(...args), warning: jest.fn(), info: jest.fn(), show: jest.fn() }), +})); +jest.mock('expo-router', () => ({ router: { back: jest.fn(), push: jest.fn(), replace: jest.fn(), canGoBack: jest.fn(() => true) }, Stack: { Screen: () => null } })); +jest.mock('nativewind', () => ({ useColorScheme: () => ({ colorScheme: 'light' }) })); +jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) })); +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: unknown) => (options && typeof options === 'object' && 'fields' in options ? `${key}|${String((options as { fields: unknown }).fields)}` : key), + }), +})); + +jest.mock('@/stores/calls/store', () => { + const state = { + callPriorities: [ + { Id: 1, Name: 'Low', Color: '#0f0', IsDefault: false }, + { Id: 3, Name: 'High', Color: '#f00', IsDefault: true }, + ], + callTypes: [{ Id: '1', Name: 'Fire' }], + fetchCallPriorities: jest.fn(), + fetchCallTypes: jest.fn(), + }; + return { useCallsStore: (selector: (s: typeof state) => unknown) => selector(state) }; +}); +jest.mock('@/stores/calls/archived-store', () => ({ + useArchivedCallsStore: { getState: () => ({ showYear: (...args: unknown[]) => mockShowYear(...args) }) }, +})); + +jest.mock('@/components/calls/dispatch-selection-modal', () => ({ + DispatchSelectionModal: (props: unknown) => { + mockDispatchModalProps = props as typeof mockDispatchModalProps; + return null; + }, +})); +jest.mock('@/components/common/date-time-field', () => { + const { TextInput } = require('react-native'); + const react = require('react'); + return { + DateTimeField: ({ value, onChange, testID }: { value: string; onChange: (value: string) => void; testID?: string }) => react.createElement(TextInput, { testID, value, onChangeText: onChange }), + }; +}); +jest.mock('@/components/operations/option-select', () => { + const { Pressable, Text, View } = require('react-native'); + const react = require('react'); + return { + OptionSelect: ({ value, options, onChange, testID }: { value: string; options: { value: string; label: string }[]; onChange: (value: string) => void; testID?: string }) => + react.createElement( + View, + { testID }, + react.createElement(Text, { testID: `${testID}-value` }, value), + ...options.map((option) => react.createElement(Pressable, { key: option.value, testID: `${testID}-${option.value}`, onPress: () => onChange(option.value) }, react.createElement(Text, null, option.label))) + ), + }; +}); +jest.mock('@/components/ui/focus-aware-status-bar', () => ({ FocusAwareStatusBar: () => null })); +jest.mock('@/components/ui/lucide-icons', () => ({ UsersIcon: () => null })); +jest.mock('@/components/ui/card', () => { + const { View } = require('react-native'); + return { Card: ({ children }: { children?: React.ReactNode }) => require('react').createElement(View, null, children) }; +}); +jest.mock('@/components/ui/button', () => { + const { Pressable, Text } = require('react-native'); + const react = require('react'); + return { + Button: ({ children, onPress, isDisabled, testID }: { children?: React.ReactNode; onPress?: () => void; isDisabled?: boolean; testID?: string }) => react.createElement(Pressable, { onPress, disabled: !!isDisabled, testID }, children), + ButtonText: ({ children }: { children?: React.ReactNode }) => react.createElement(Text, null, children), + }; +}); +jest.mock('@/components/ui/box', () => { + const { View } = require('react-native'); + return { Box: ({ children }: { children?: React.ReactNode }) => require('react').createElement(View, null, children) }; +}); +jest.mock('@/components/ui/text', () => { + const { Text } = require('react-native'); + return { Text: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => require('react').createElement(Text, { testID }, children) }; +}); +jest.mock('@/components/ui/form-control', () => { + const { Text, View } = require('react-native'); + const react = require('react'); + const passthrough = ({ children }: { children?: React.ReactNode }) => react.createElement(View, null, children); + return { + FormControl: passthrough, + FormControlLabel: passthrough, + FormControlLabelText: ({ children }: { children?: React.ReactNode }) => react.createElement(Text, null, children), + }; +}); +jest.mock('@/components/ui/input', () => { + const { TextInput, View } = require('react-native'); + const react = require('react'); + return { + Input: ({ children }: { children?: React.ReactNode }) => react.createElement(View, null, children), + InputField: (props: Record) => react.createElement(TextInput, props), + }; +}); +jest.mock('@/components/ui/textarea', () => { + const { TextInput, View } = require('react-native'); + const react = require('react'); + return { + Textarea: ({ children }: { children?: React.ReactNode }) => react.createElement(View, null, children), + TextareaInput: (props: Record) => react.createElement(TextInput, props), + }; +}); +jest.mock('@/components/ui/switch', () => { + const { Switch } = require('react-native'); + return { Switch }; +}); + +const mockCreateArchivedCall = createArchivedCall as jest.MockedFunction; + +const fillRequired = () => { + fireEvent.changeText(screen.getByTestId('archived-call-name'), 'Structure Fire'); + fireEvent.changeText(screen.getByTestId('archived-call-nature'), 'Smoke showing'); + fireEvent.changeText(screen.getByTestId('archived-call-logged-on'), '2025-06-01T10:00:00.000Z'); +}; + +describe('Add Archived Call', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDispatchModalProps = null; + mockCreateArchivedCall.mockResolvedValue({ Id: '99' } as never); + }); + + it("starts on the department's default priority and the Closed close type", () => { + render(); + + expect(screen.getByTestId('archived-call-priority-value').props.children).toBe('3'); + expect(screen.getByTestId('archived-call-state-value').props.children).toBe('1'); + }); + + it('says what is missing instead of saving', async () => { + render(); + + await act(async () => { + fireEvent.press(screen.getByTestId('archived-call-save')); + }); + + expect(mockToastError).toHaveBeenCalledWith('archived_calls.name_required'); + expect(mockCreateArchivedCall).not.toHaveBeenCalled(); + }); + + it('refuses a call logged in the future', async () => { + render(); + fillRequired(); + fireEvent.changeText(screen.getByTestId('archived-call-logged-on'), new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()); + + await act(async () => { + fireEvent.press(screen.getByTestId('archived-call-save')); + }); + + expect(mockToastError).toHaveBeenCalledWith('archived_calls.logged_on_future'); + expect(mockCreateArchivedCall).not.toHaveBeenCalled(); + }); + + it('saves the call closed, with who it went to recorded, and opens the list on its year', async () => { + const { router } = require('expo-router'); + render(); + fillRequired(); + fireEvent.press(screen.getByTestId('archived-call-type-Fire')); + fireEvent.press(screen.getByTestId('archived-call-state-7')); + fireEvent.changeText(screen.getByTestId('archived-call-closed-on'), '2025-06-01T11:00:00.000Z'); + fireEvent.changeText(screen.getByTestId('archived-call-closed-notes'), 'Detector fault'); + fireEvent(screen.getByTestId('archived-call-recalculate'), 'valueChange', true); + act(() => { + mockDispatchModalProps?.onConfirm({ everyone: false, users: ['u1'], groups: [], roles: [], units: ['5'] }); + }); + + expect(screen.getByTestId('archived-call-dispatched-summary').props.children).toBe('archived_calls.dispatched_summary'); + + await act(async () => { + fireEvent.press(screen.getByTestId('archived-call-save')); + }); + + expect(mockCreateArchivedCall).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Structure Fire', + nature: 'Smoke showing', + priority: 3, + type: 'Fire', + state: 7, + loggedOnUtc: '2025-06-01T10:00:00.000Z', + closedOnUtc: '2025-06-01T11:00:00.000Z', + closedNotes: 'Detector fault', + recalculateCallNumbers: true, + dispatchUsers: ['u1'], + dispatchUnits: ['5'], + }) + ); + expect(mockToastSuccess).toHaveBeenCalledWith('archived_calls.save_success'); + expect(mockShowYear).toHaveBeenCalledWith('2025'); + expect(router.back).toHaveBeenCalled(); + }); + + it("shows the department's missing required fields when the server refuses the call", async () => { + mockCreateArchivedCall.mockRejectedValueOnce({ isAxiosError: true, response: { status: 400, data: 'Required call fields are missing: Address' } }); + render(); + fillRequired(); + + await act(async () => { + fireEvent.press(screen.getByTestId('archived-call-save')); + }); + + await waitFor(() => expect(mockToastError).toHaveBeenCalledWith(expect.stringContaining('calls.required_fields_missing'))); + }); + + it('falls back to its own message when the server gives no reason', async () => { + mockCreateArchivedCall.mockRejectedValueOnce(new Error('Network Error')); + render(); + fillRequired(); + + await act(async () => { + fireEvent.press(screen.getByTestId('archived-call-save')); + }); + + expect(mockToastError).toHaveBeenCalledWith('archived_calls.save_error'); + }); +}); diff --git a/src/__tests__/app/call/new-call-field-policy.test.tsx b/src/__tests__/app/call/new-call-field-policy.test.tsx index 43510b2c..31a2f783 100644 --- a/src/__tests__/app/call/new-call-field-policy.test.tsx +++ b/src/__tests__/app/call/new-call-field-policy.test.tsx @@ -9,6 +9,7 @@ import NewCall from '../../../app/call/new'; const mockToastError = jest.fn(); let mockTemplatesModalProps: { onSelect: (template: { name?: string; nature?: string; type?: string; priority?: number }) => void } | null = null; +let mockUdfProps: { onValuesChange: (values: { UdfFieldId: string; Value: string }[]) => void; onFieldsLoaded?: (fields: unknown[]) => void } | null = null; let mockDispatchModalProps: { onConfirm: (selection: { everyone: boolean; users: string[]; groups: string[]; roles: string[]; units: string[] }) => void } | null = null; jest.mock('@/api/calls/calls', () => ({ createCall: jest.fn() })); @@ -65,7 +66,12 @@ jest.mock('@/components/calls/dispatch-selection-modal', () => ({ jest.mock('@/components/calls/contact-picker-modal', () => ({ ContactPickerModal: () => null })); jest.mock('@/components/calls/linked-calls-modal', () => ({ LinkedCallsModal: () => null })); jest.mock('@/components/calls/protocol-selector-modal', () => ({ ProtocolSelectorModal: () => null })); -jest.mock('@/components/calls/udf-fields-renderer', () => ({ UdfFieldsRenderer: () => null })); +jest.mock('@/components/calls/udf-fields-renderer', () => ({ + UdfFieldsRenderer: (props: unknown) => { + mockUdfProps = props as typeof mockUdfProps; + return null; + }, +})); jest.mock('@/components/common/loading', () => ({ Loading: () => null })); jest.mock('@/components/common/date-time-field', () => { const { TextInput } = require('react-native'); @@ -179,9 +185,59 @@ describe('New call screen and the call field policy', () => { jest.clearAllMocks(); mockTemplatesModalProps = null; mockDispatchModalProps = null; + mockUdfProps = null; mockedCreateCall.mockResolvedValue({ Id: '101' }); }); + it('opens a card holding a field the department requires', async () => { + const { getByPlaceholderText } = await renderNewCall([{ Key: NewCallFieldKeys.ContactInfo, Visible: true, Required: true }]); + + // The contact card is folded by default; a required field in it opens it without a tap. + await waitFor(() => expect(getByPlaceholderText('calls.contact_info_placeholder')).toBeTruthy()); + }); + + it('holds the call until a required custom field is filled in, then saves the values with the call', async () => { + const { getByText } = await renderNewCall([]); + const referral = { UdfFieldId: 'referral', Name: 'referral', Label: 'Referral Source', IsRequired: true }; + const language = { UdfFieldId: 'language', Name: 'language', Label: 'Primary Language', IsRequired: false }; + act(() => { + mockUdfProps?.onFieldsLoaded?.([referral, language]); + mockUdfProps?.onValuesChange([ + { UdfFieldId: 'referral', Value: '' }, + { UdfFieldId: 'language', Value: '' }, + ]); + }); + pickUnit(); + + fireEvent.press(getByText('calls.create')); + await waitFor(() => expect(mockToastError).toHaveBeenCalledWith('calls.required_fields_missing|Referral Source')); + expect(mockedCreateCall).not.toHaveBeenCalled(); + + act(() => { + mockUdfProps?.onValuesChange([ + { UdfFieldId: 'referral', Value: '988' }, + { UdfFieldId: 'language', Value: '' }, + ]); + }); + fireEvent.press(getByText('calls.create')); + + await waitFor(() => expect(mockedCreateCall).toHaveBeenCalledTimes(1)); + expect(mockedCreateCall.mock.calls[0][0].udfValues).toEqual([ + { UdfFieldId: 'referral', Value: '988' }, + { UdfFieldId: 'language', Value: '' }, + ]); + }); + + it('leaves custom field values out when their definition never loaded', async () => { + const { getByText } = await renderNewCall([]); + pickUnit(); + + fireEvent.press(getByText('calls.create')); + + await waitFor(() => expect(mockedCreateCall).toHaveBeenCalledTimes(1)); + expect(mockedCreateCall.mock.calls[0][0].udfValues).toBeUndefined(); + }); + it('offers the identifiers, gated by the policy', async () => { const { getByText, getByTestId, queryByTestId } = await renderNewCall([{ Key: NewCallFieldKeys.ReferenceId, Visible: false, Required: false }]); diff --git a/src/api/calls/__tests__/archived-calls.test.ts b/src/api/calls/__tests__/archived-calls.test.ts new file mode 100644 index 00000000..ea5a9fb0 --- /dev/null +++ b/src/api/calls/__tests__/archived-calls.test.ts @@ -0,0 +1,104 @@ +import { createArchivedCall, getArchivedCalls, getCallYears } from '../calls'; + +jest.mock('../../common/client', () => { + const get = jest.fn(); + const post = jest.fn(); + // Records the endpoint path of every request, so a test can tell which API a call went to. + const endpoint = jest.fn(); + return { + createApiEndpoint: jest.fn((path: string) => ({ + get: (...args: unknown[]) => { + endpoint(path); + return get(...args); + }, + post: (...args: unknown[]) => { + endpoint(path); + return post(...args); + }, + put: jest.fn(), + delete: jest.fn(), + })), + __mockGet: get, + __mockPost: post, + __mockEndpoint: endpoint, + }; +}); + +const { + __mockGet: mockGet, + __mockPost: mockPost, + __mockEndpoint: mockEndpoint, +} = jest.requireMock('../../common/client') as { __mockGet: jest.Mock; __mockPost: jest.Mock; __mockEndpoint: jest.Mock }; + +describe('archived calls API', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGet.mockResolvedValue({ data: { Data: [] } }); + mockPost.mockResolvedValue({ data: { Id: '42' } }); + }); + + it('asks for the call years', async () => { + await getCallYears(); + + expect(mockEndpoint).toHaveBeenCalledWith('/Calls/GetCallYears'); + }); + + it('asks for one year of archived calls', async () => { + await getArchivedCalls('2025'); + + expect(mockEndpoint).toHaveBeenCalledWith('/Calls/GetArchivedCalls'); + expect(mockGet).toHaveBeenCalledWith(expect.objectContaining({ year: '2025' })); + }); + + it('saves an archived call with its times, close type and the dispatch it records', async () => { + const result = await createArchivedCall({ + name: 'Structure Fire', + nature: 'Smoke showing', + priority: 2, + type: 'Fire', + address: '1 Main St', + latitude: 39.12, + longitude: -119.75, + dispatchUsers: ['u1'], + dispatchUnits: ['5'], + loggedOnUtc: '2026-10-01T10:00:00.000Z', + closedOnUtc: '2026-10-01T11:30:00.000Z', + state: 3, + closedNotes: 'Nothing found', + recalculateCallNumbers: true, + }); + + expect(result).toEqual({ Id: '42' }); + expect(mockEndpoint).toHaveBeenCalledWith('/Calls/SaveArchivedCall'); + expect(mockPost).toHaveBeenCalledWith({ + Name: 'Structure Fire', + Nature: 'Smoke showing', + Note: '', + Address: '1 Main St', + Geolocation: '39.12,-119.75', + Priority: 2, + Type: 'Fire', + ContactName: '', + ContactInfo: '', + IncidentId: '', + ExternalId: '', + ReferenceId: '', + DispatchList: 'P:u1|U:5', + LoggedOnUtc: '2026-10-01T10:00:00.000Z', + ClosedOnUtc: '2026-10-01T11:30:00.000Z', + State: 3, + ClosedNotes: 'Nothing found', + RecalculateCallNumbers: true, + }); + }); + + it('leaves the close time out when none was picked, and sends no location without one', async () => { + await createArchivedCall({ name: 'Alarm', nature: 'Commercial alarm', priority: 1, loggedOnUtc: '2026-10-01T10:00:00.000Z', state: 7 }); + + const sent = mockPost.mock.calls[0][0] as Record; + expect(sent).not.toHaveProperty('ClosedOnUtc'); + expect(sent.Geolocation).toBe(''); + expect(sent.DispatchList).toBe(''); + expect(sent.RecalculateCallNumbers).toBe(false); + }); +}); diff --git a/src/api/calls/calls.ts b/src/api/calls/calls.ts index c9263070..961705bb 100644 --- a/src/api/calls/calls.ts +++ b/src/api/calls/calls.ts @@ -1,11 +1,14 @@ import { formatGeolocation } from '@/lib/call-geolocation'; import { type ActiveCallsResult } from '@/models/v4/calls/activeCallsResult'; +import { type ArchivedCallsResult } from '@/models/v4/calls/archivedCallsResult'; import { type CallExtraDataResult } from '@/models/v4/calls/callExtraDataResult'; import { type CallResult } from '@/models/v4/calls/callResult'; +import { type CallYearsResult } from '@/models/v4/calls/callYearsResult'; import { type DispatchCallNowResult } from '@/models/v4/calls/dispatchCallNowResult'; import { type PendingCallsResult } from '@/models/v4/calls/pendingCallsResult'; import { type SaveCallResult } from '@/models/v4/calls/saveCallResult'; import { type ScheduledCallsResult } from '@/models/v4/calls/scheduledCallsResult'; +import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput'; import { createApiEndpoint } from '../common/client'; @@ -20,6 +23,9 @@ const updateCallApi = createApiEndpoint('/Calls/EditCall'); const closeCallApi = createApiEndpoint('/Calls/CloseCall'); const deleteCallApi = createApiEndpoint('/Calls/DeleteCall'); const updateScheduledDispatchTimeApi = createApiEndpoint('/Calls/UpdateScheduledDispatchTime'); +const callYearsApi = createApiEndpoint('/Calls/GetCallYears'); +const archivedCallsApi = createApiEndpoint('/Calls/GetArchivedCalls'); +const saveArchivedCallApi = createApiEndpoint('/Calls/SaveArchivedCall'); export const getCalls = async () => { // Add timestamp to prevent any caching @@ -138,6 +144,12 @@ export interface CreateCallRequest { * keeps that proposal instead of walking the decision graph again. Sent only when set. */ runCardOutcomeNodeKey?: string; + /** + * The call's custom field values, saved with the call. Sent whenever the form showed the fields (an empty list + * included), so the server checks the department's required fields before the call is created; undefined omits + * the property (no fields shown) and the server does not check them. + */ + udfValues?: UdfFieldValueInput[]; } export interface UpdateCallRequest { @@ -192,6 +204,8 @@ export interface UpdateCallRequest { notifyCancelledEntities?: boolean; /** Conditional run cards: per-unit response codes, as on {@link CreateCallRequest.unitResponseCodes}. Sent only when set. */ unitResponseCodes?: string; + /** The call's custom field values, replacing the stored ones; as on {@link CreateCallRequest.udfValues}. */ + udfValues?: UdfFieldValueInput[]; } export interface CloseCallRequest { @@ -260,6 +274,10 @@ const linkAndProtocolsFor = (callData: CreateCallRequest | UpdateCallRequest): { ...(Array.isArray(callData.protocolIds) ? { ProtocolIds: callData.protocolIds } : {}), }); +/** UdfValues only when the caller set it, for the same reason: a missing property is not checked. */ +const udfValuesFor = (callData: CreateCallRequest | UpdateCallRequest): { UdfValues?: UdfFieldValueInput[] } => + Array.isArray(callData.udfValues) ? { UdfValues: callData.udfValues.map((v) => ({ UdfFieldId: v.UdfFieldId, Value: v.Value })) } : {}; + export const createCall = async (callData: CreateCallRequest) => { const dispatchList = dispatchListFor(callData); @@ -292,6 +310,7 @@ export const createCall = async (callData: CreateCallRequest) => { // Conditional run cards only; left out otherwise so older servers see the request they always did. ...(callData.unitResponseCodes ? { UnitResponseCodes: callData.unitResponseCodes } : {}), ...(callData.runCardOutcomeNodeKey ? { RunCardOutcomeNodeKey: callData.runCardOutcomeNodeKey } : {}), + ...udfValuesFor(callData), }; const response = await createCallApi.post(data); @@ -328,6 +347,7 @@ export const updateCall = async (callData: UpdateCallRequest) => { RebroadcastCall: callData.rebroadcastCall ?? false, NotifyCancelledEntities: callData.notifyCancelledEntities ?? false, ...(callData.unitResponseCodes ? { UnitResponseCodes: callData.unitResponseCodes } : {}), + ...udfValuesFor(callData), }; const response = await updateCallApi.put(data); @@ -370,3 +390,84 @@ export const updateScheduledDispatchTime = async (request: UpdateScheduledDispat const response = await updateScheduledDispatchTimeApi.put(data); return response.data; }; + +/** The years the department has calls in, newest first. The server answers Status "NotFound" with an empty list when there are none. */ +export const getCallYears = async () => { + const response = await callYearsApi.get({ _t: Date.now() }); + return response.data; +}; + +/** + * Archived calls: the closed calls (Closed through False Alarm, States 1-7) logged in `year`, newest first, + * trimmed by group-scoped dispatch like the active list. "NotFound" with an empty list when there are none. + */ +export const getArchivedCalls = async (year: number | string) => { + const response = await archivedCallsApi.get({ year, _t: Date.now() }); + return response.data; +}; + +/** + * A call that already happened, entered after the fact: saved closed, with the time it was logged. Who it went + * to is recorded on the call only; nobody is notified. + */ +export interface CreateArchivedCallRequest { + name: string; + nature: string; + note?: string; + address?: string; + latitude?: number; + longitude?: number; + priority: number; + type?: string; + contactName?: string; + contactInfo?: string; + incidentId?: string; + externalId?: string; + referenceId?: string; + dispatchUsers?: string[]; + dispatchGroups?: string[]; + dispatchRoles?: string[]; + dispatchUnits?: string[]; + dispatchEveryone?: boolean; + /** When the call happened, as an ISO 8601 UTC instant. Not in the future. */ + loggedOnUtc: string; + /** When it was closed, as an ISO 8601 UTC instant; undefined lets the server use now. Not before loggedOnUtc. */ + closedOnUtc?: string; + /** How it was closed: a CallState from Closed (1) to False Alarm (7). */ + state: number; + closedNotes?: string; + /** Renumber the year's calls by time logged, so the archived call takes its place in the sequence. */ + recalculateCallNumbers?: boolean; +} + +export const createArchivedCall = async (callData: CreateArchivedCallRequest) => { + const data = { + Name: callData.name, + Nature: callData.nature, + Note: callData.note || '', + Address: callData.address || '', + Geolocation: formatGeolocation(callData.latitude, callData.longitude), + Priority: callData.priority, + Type: callData.type || '', + ContactName: callData.contactName || '', + ContactInfo: callData.contactInfo || '', + IncidentId: callData.incidentId || '', + ExternalId: callData.externalId || '', + ReferenceId: callData.referenceId || '', + DispatchList: buildDispatchList({ + everyone: callData.dispatchEveryone, + users: callData.dispatchUsers, + groups: callData.dispatchGroups, + roles: callData.dispatchRoles, + units: callData.dispatchUnits, + }), + LoggedOnUtc: callData.loggedOnUtc, + ...(callData.closedOnUtc ? { ClosedOnUtc: callData.closedOnUtc } : {}), + State: callData.state, + ClosedNotes: callData.closedNotes || '', + RecalculateCallNumbers: callData.recalculateCallNumbers === true, + }; + + const response = await saveArchivedCallApi.post(data); + return response.data; +}; diff --git a/src/app/(app)/__tests__/archived-calls.test.tsx b/src/app/(app)/__tests__/archived-calls.test.tsx new file mode 100644 index 00000000..3dceaee0 --- /dev/null +++ b/src/app/(app)/__tests__/archived-calls.test.tsx @@ -0,0 +1,264 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { type CallResultData } from '@/models/v4/calls/callResultData'; + +import ArchivedCalls from '../archived-calls'; + +jest.mock('expo-router', () => ({ + router: { push: jest.fn() }, + Stack: { Screen: () => null }, + useFocusEffect: (callback: () => void) => { + const React = require('react'); + // Runs once on mount, like a screen gaining focus. + // eslint-disable-next-line react-hooks/exhaustive-deps + React.useEffect(callback, []); + }, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string, options?: Record) => (options && 'count' in options ? `${key}:${options.count}/${options.total}` : key) }), +})); + +jest.mock('nativewind', () => ({ + useColorScheme: () => ({ colorScheme: 'light' }), +})); + +jest.mock('@/hooks/use-analytics', () => ({ + useAnalytics: () => ({ trackEvent: jest.fn() }), +})); + +jest.mock('@/components/ui/focus-aware-status-bar', () => ({ + FocusAwareStatusBar: () => null, +})); + +jest.mock('@/components/ui/input', () => { + const { TextInput, View } = require('react-native'); + return { + Input: ({ children }: any) => {children}, + InputField: ({ value, onChangeText, placeholder }: any) => , + InputIcon: () => null, + InputSlot: ({ children }: any) => {children}, + }; +}); + +jest.mock('@/components/operations/option-select', () => ({ + OptionSelect: ({ value, options, onChange, testID }: any) => { + const { Pressable, Text, View } = require('react-native'); + return ( + + {value} + {options.map((option: { value: string; label: string }) => ( + onChange(option.value)}> + {option.label} + + ))} + + ); + }, +})); + +jest.mock('@/components/common/date-time-field', () => ({ + DateTimeField: ({ value, onChange, testID }: any) => { + const { TextInput } = require('react-native'); + return ; + }, +})); + +jest.mock('@/components/common/loading', () => ({ + Loading: ({ text }: any) => { + const { Text } = require('react-native'); + return {text}; + }, +})); + +jest.mock('@/components/common/zero-state', () => ({ + __esModule: true, + default: ({ heading }: any) => { + const { Text } = require('react-native'); + return {heading}; + }, +})); + +const mockArchivedState = { + years: ['2026', '2025'], + selectedYear: '2026' as string | null, + archivedCalls: [] as CallResultData[], + isLoading: false, + error: null as string | null, + lastFetched: 0, + load: jest.fn(() => Promise.resolve()), + selectYear: jest.fn(() => Promise.resolve()), + fetchArchivedCalls: jest.fn(() => Promise.resolve()), +}; +jest.mock('@/stores/calls/archived-store', () => ({ + useArchivedCallsStore: Object.assign((selector: (state: typeof mockArchivedState) => unknown) => selector(mockArchivedState), { getState: () => mockArchivedState }), +})); + +const mockCallsState = { + callPriorities: [ + { Id: 1, Name: 'Routine', Color: '#00ff00' }, + { Id: 2, Name: 'Emergency', Color: '#ff0000' }, + ], + fetchCallPriorities: jest.fn(), +}; +jest.mock('@/stores/calls/store', () => ({ + useCallsStore: (selector: (state: typeof mockCallsState) => unknown) => selector(mockCallsState), +})); + +const mockSecurity = { canUserCreateCalls: true }; +jest.mock('@/stores/security/store', () => ({ + useSecurityStore: () => mockSecurity, +})); + +const archivedCall = (callId: string, overrides: Partial): CallResultData => + ({ + CallId: callId, + Number: `26-${callId}`, + Name: 'Call', + Nature: '', + Type: 'Fire', + Priority: 1, + Address: '1 Main St', + LoggedOn: '2026-03-05T14:22:00', + ClosedOn: '2026-03-05T16:00:00', + State: 1, + ...overrides, + }) as CallResultData; + +describe('ArchivedCalls screen', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockArchivedState.archivedCalls = [ + archivedCall('41', { Name: 'Welfare check', Nature: 'Adult in distress', Type: 'EMS', Priority: 2, State: 2 }), + archivedCall('40', { Name: 'Structure fire', Nature: 'Smoke showing', State: 1 }), + ]; + mockArchivedState.selectedYear = '2026'; + mockArchivedState.lastFetched = 0; + mockArchivedState.isLoading = false; + mockArchivedState.error = null; + mockSecurity.canUserCreateCalls = true; + }); + + it('loads the years and calls, and the priorities, when shown', () => { + render(); + + expect(mockArchivedState.load).toHaveBeenCalledTimes(1); + expect(mockCallsState.fetchCallPriorities).toHaveBeenCalled(); + }); + + it('reuses a year loaded a moment ago when shown again', () => { + mockArchivedState.lastFetched = Date.now(); + render(); + + expect(mockArchivedState.load).not.toHaveBeenCalled(); + }); + + it('lists the archived calls with how and when they closed', () => { + render(); + + expect(screen.getByText('26-41')).toBeTruthy(); + expect(screen.getByText('Welfare check')).toBeTruthy(); + expect(screen.getByText('Adult in distress')).toBeTruthy(); + expect(screen.getByText('call_detail.close_call_types.cancelled')).toBeTruthy(); + expect(screen.getByText('call_detail.close_call_types.closed')).toBeTruthy(); + expect(screen.getAllByText('Mar 05, 2026 02:22 pm')).toHaveLength(2); + expect(screen.getAllByText('Mar 05, 2026 04:00 pm')).toHaveLength(2); + }); + + it('opens the call when a row is pressed', () => { + const { router } = require('expo-router'); + render(); + + fireEvent.press(screen.getByTestId('archived-call-row-40')); + + expect(router.push).toHaveBeenCalledWith('/call/40'); + }); + + it('searches the calls and says how many match', () => { + render(); + + fireEvent.changeText(screen.getByTestId('archived-calls-search'), 'distress'); + + expect(screen.getByText('Welfare check')).toBeTruthy(); + expect(screen.queryByText('Structure fire')).toBeNull(); + expect(screen.getByText('archived_calls.showing:1/2')).toBeTruthy(); + }); + + it('filters by close type, and clears the filters', () => { + render(); + + fireEvent.press(screen.getByTestId('archived-calls-filters-toggle')); + fireEvent.press(screen.getByTestId('archived-filter-state-1')); + + expect(screen.queryByText('Welfare check')).toBeNull(); + expect(screen.getByText('Structure fire')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('archived-filters-clear')); + + expect(screen.getByText('Welfare check')).toBeTruthy(); + }); + + it('filters by priority and by the types the calls carry', () => { + render(); + + fireEvent.press(screen.getByTestId('archived-calls-filters-toggle')); + fireEvent.press(screen.getByTestId('archived-filter-type-EMS')); + + expect(screen.getByText('Welfare check')).toBeTruthy(); + expect(screen.queryByText('Structure fire')).toBeNull(); + + fireEvent.press(screen.getByTestId('archived-filter-type-EMS')); + fireEvent.press(screen.getByTestId('archived-filter-priority-1')); + + expect(screen.queryByText('Welfare check')).toBeNull(); + expect(screen.getByText('Structure fire')).toBeTruthy(); + }); + + it('switches the year', () => { + render(); + + fireEvent.press(screen.getByTestId('archived-calls-year-2025')); + + expect(mockArchivedState.selectYear).toHaveBeenCalledWith('2025'); + }); + + it('opens Add Archived Call for a user who can create calls, and hides it otherwise', () => { + const { router } = require('expo-router'); + const { rerender } = render(); + + fireEvent.press(screen.getByTestId('archived-calls-add')); + expect(router.push).toHaveBeenCalledWith('/call/new/archived'); + + mockSecurity.canUserCreateCalls = false; + rerender(); + expect(screen.queryByTestId('archived-calls-add')).toBeNull(); + }); + + it('says when the year has no archived calls, and when nothing matches', () => { + const { rerender } = render(); + + fireEvent.changeText(screen.getByTestId('archived-calls-search'), 'no such call'); + expect(screen.getByText('archived_calls.no_matches')).toBeTruthy(); + + mockArchivedState.archivedCalls = []; + rerender(); + expect(screen.getByText('archived_calls.no_archived_calls')).toBeTruthy(); + }); + + it('shows the error when nothing could be loaded', () => { + mockArchivedState.archivedCalls = []; + mockArchivedState.error = 'Network down'; + render(); + + expect(screen.getByText('common.errorOccurred')).toBeTruthy(); + }); + + it('keeps the rows on screen while a refresh is running', () => { + mockArchivedState.isLoading = true; + render(); + + expect(screen.queryByTestId('loading')).toBeNull(); + expect(screen.getByText('Welfare check')).toBeTruthy(); + }); +}); diff --git a/src/app/(app)/archived-calls.tsx b/src/app/(app)/archived-calls.tsx new file mode 100644 index 00000000..b274addc --- /dev/null +++ b/src/app/(app)/archived-calls.tsx @@ -0,0 +1,537 @@ +import { type Href, router, Stack, useFocusEffect } from 'expo-router'; +import { ArchiveIcon, FilterIcon, PlusIcon, RefreshCwIcon, Search, X } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, RefreshControl, ScrollView, StyleSheet, Text as RNText, View } from 'react-native'; + +import { DateTimeField } from '@/components/common/date-time-field'; +import { Loading } from '@/components/common/loading'; +import { TypeIcon } from '@/components/common/type-icon'; +import ZeroState from '@/components/common/zero-state'; +import { OptionSelect } from '@/components/operations/option-select'; +import { Box } from '@/components/ui/box'; +import { FlatList } from '@/components/ui/flat-list'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; +import { useAnalytics } from '@/hooks/use-analytics'; +import { ARCHIVED_CALL_STATES, type ArchivedCallFilters, archivedCallStateLabelKey, archivedCallTypes, countActiveArchivedCallFilters, EMPTY_ARCHIVED_CALL_FILTERS, filterArchivedCalls } from '@/lib/archived-calls'; +import { formatDateForDisplay, parseDateISOString } from '@/lib/utils'; +import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { useArchivedCallsStore } from '@/stores/calls/archived-store'; +import { useCallsStore } from '@/stores/calls/store'; +import { useSecurityStore } from '@/stores/security/store'; + +const DATE_FORMAT = 'MMM dd, yyyy hh:mm t'; + +/** How long a loaded year counts as current when the screen comes back into view. */ +const ARCHIVED_CALLS_FRESH_MS = 60 * 1000; + +const toggled = (values: T[], value: T): T[] => (values.includes(value) ? values.filter((v) => v !== value) : [...values, value]); + +const FilterChip: React.FC<{ label: string; active: boolean; onPress: () => void; testID: string; color?: string }> = ({ label, active, onPress, testID, color }) => ( + + {color ? : null} + {label} + +); + +/** + * Archived calls: the department's closed calls, a year at a time, with search and filters, and the way in to + * entering a call that already happened (Add Archived Call). + */ +export default function ArchivedCalls() { + const years = useArchivedCallsStore((s) => s.years); + const selectedYear = useArchivedCallsStore((s) => s.selectedYear); + const archivedCalls = useArchivedCallsStore((s) => s.archivedCalls); + const isLoading = useArchivedCallsStore((s) => s.isLoading); + const error = useArchivedCallsStore((s) => s.error); + const load = useArchivedCallsStore((s) => s.load); + const selectYear = useArchivedCallsStore((s) => s.selectYear); + const fetchArchivedCalls = useArchivedCallsStore((s) => s.fetchArchivedCalls); + const callPriorities = useCallsStore((s) => s.callPriorities); + const fetchCallPriorities = useCallsStore((s) => s.fetchCallPriorities); + const { canUserCreateCalls } = useSecurityStore(); + const { t } = useTranslation(); + const { trackEvent } = useAnalytics(); + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === 'dark'; + const [filters, setFilters] = useState(EMPTY_ARCHIVED_CALL_FILTERS); + const [showFilters, setShowFilters] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + + const themedStyles = useMemo(() => getThemedStyles(isDark), [isDark]); + + useFocusEffect( + useCallback(() => { + fetchCallPriorities(); + // Archived calls rarely change: coming back from a call reuses the year loaded a moment ago. A call just added + // (the store's showYear) or a refresh always loads. + if (Date.now() - useArchivedCallsStore.getState().lastFetched > ARCHIVED_CALLS_FRESH_MS) { + void load(); + } + }, [fetchCallPriorities, load]) + ); + + useEffect(() => { + trackEvent('archived_calls_view_rendered', { + callsCount: archivedCalls.length, + year: selectedYear ?? '', + }); + }, [trackEvent, archivedCalls.length, selectedYear]); + + const handleRefresh = useCallback(() => { + setIsRefreshing(true); + fetchArchivedCalls().finally(() => setIsRefreshing(false)); + }, [fetchArchivedCalls]); + + const priorityById = useMemo(() => { + const map = new Map(); + callPriorities.forEach((p) => map.set(p.Id, p)); + return map; + }, [callPriorities]); + + const yearOptions = useMemo(() => years.map((year) => ({ value: year, label: year })), [years]); + const callTypes = useMemo(() => archivedCallTypes(archivedCalls), [archivedCalls]); + const filteredCalls = useMemo(() => filterArchivedCalls(archivedCalls, filters), [archivedCalls, filters]); + const activeFilterCount = countActiveArchivedCallFilters(filters); + const isFiltered = activeFilterCount > 0 || filters.query.trim().length > 0; + + const updateFilters = (changes: Partial) => setFilters((current) => ({ ...current, ...changes })); + + const renderFilters = () => ( + + {t('archived_calls.table_state')} + + {ARCHIVED_CALL_STATES.map((state) => ( + updateFilters({ states: toggled(filters.states, state.value) })} + testID={`archived-filter-state-${state.value}`} + /> + ))} + + + {callPriorities.length > 0 ? ( + <> + {t('scheduled_calls.table_priority')} + + {callPriorities.map((priority) => ( + updateFilters({ priorities: toggled(filters.priorities, priority.Id) })} + testID={`archived-filter-priority-${priority.Id}`} + /> + ))} + + + ) : null} + + {callTypes.length > 0 ? ( + <> + {t('scheduled_calls.table_type')} + + {callTypes.map((type) => ( + updateFilters({ types: toggled(filters.types, type) })} testID={`archived-filter-type-${type}`} /> + ))} + + + ) : null} + + {t('archived_calls.logged_between')} + + + updateFilters({ fromDate })} label={t('archived_calls.from_date')} mode="date" testID="archived-filter-from" /> + + + updateFilters({ toDate })} label={t('archived_calls.to_date')} mode="date" testID="archived-filter-to" /> + + + + {activeFilterCount > 0 ? ( + setFilters((current) => ({ ...EMPTY_ARCHIVED_CALL_FILTERS, query: current.query }))} accessibilityRole="button" testID="archived-filters-clear" style={styles.clearFilters}> + {t('archived_calls.clear_filters')} + + ) : null} + + ); + + const renderContent = () => { + // Only the first load replaces the table; a refresh keeps the rows on screen. + if (isLoading && archivedCalls.length === 0) { + return ; + } + + if (error && archivedCalls.length === 0) { + return ; + } + + if (archivedCalls.length === 0) { + return ; + } + + if (filteredCalls.length === 0) { + return ; + } + + return ( + + + {/* Table Header */} + + {t('scheduled_calls.table_number')} + {t('scheduled_calls.table_name')} + {t('scheduled_calls.table_type')} + {t('scheduled_calls.table_priority')} + {t('archived_calls.table_state')} + {t('scheduled_calls.table_address')} + {t('archived_calls.table_logged')} + {t('archived_calls.table_closed')} + + + {/* Table Rows */} + + testID="archived-calls-list" + data={filteredCalls} + renderItem={({ item, index }: { item: CallResultData; index: number }) => { + const priority = priorityById.get(item.Priority); + const stateLabelKey = archivedCallStateLabelKey(item.State); + const loggedOn = formatDateForDisplay(parseDateISOString(item.LoggedOn), DATE_FORMAT); + const closedOn = formatDateForDisplay(parseDateISOString(item.ClosedOn), DATE_FORMAT); + const rowBg = { backgroundColor: index % 2 === 0 ? themedStyles.rowEvenBg : themedStyles.rowOddBg }; + + return ( + router.push(`/call/${item.CallId}` as Href)} style={[styles.tableRow, { borderBottomColor: themedStyles.borderColor }, rowBg]} testID={`archived-call-row-${item.CallId}`}> + + + {item.Number || item.CallId} + + + + + {item.Name} + + {item.Nature ? ( + + {item.Nature} + + ) : null} + + + + + + {item.Type || '-'} + + + + + + + + {priority?.Name || '-'} + + + + + + {stateLabelKey ? t(stateLabelKey) : '-'} + + + + + {item.Address || '-'} + + + + + {loggedOn || '-'} + + + + + {closedOn || '-'} + + + + ); + }} + keyExtractor={(item: CallResultData) => item.CallId} + refreshControl={} + contentContainerStyle={{ paddingBottom: 20 }} + /> + + + ); + }; + + return ( + + + + + + + + + + + updateFilters({ query })} /> + {filters.query ? ( + updateFilters({ query: '' })}> + + + ) : null} + + + + + {/* Keyed by the year: the store picks one after the years load, and the select only shows the label it started with. */} + void selectYear(year)} + isDisabled={years.length === 0} + testID="archived-calls-year" + /> + + + setShowFilters((shown) => !shown)} + accessibilityRole="button" + accessibilityState={{ expanded: showFilters }} + accessibilityLabel={t('archived_calls.filters')} + testID="archived-calls-filters-toggle" + style={[styles.toolbarButton, { borderColor: themedStyles.borderColor, backgroundColor: showFilters || activeFilterCount > 0 ? themedStyles.activeButtonBg : themedStyles.panelBg }]} + > + + {activeFilterCount > 0 ? `${t('archived_calls.filters')} (${activeFilterCount})` : t('archived_calls.filters')} + + + + + + + {canUserCreateCalls ? ( + router.push('/call/new/archived' as Href)} + accessibilityRole="button" + testID="archived-calls-add" + style={[styles.toolbarButton, styles.addButton, { backgroundColor: themedStyles.addColor, borderColor: themedStyles.addColor }]} + > + + {t('archived_calls.add')} + + ) : null} + + + {showFilters ? renderFilters() : null} + + {archivedCalls.length > 0 && isFiltered ? ( + + {t('archived_calls.showing', { count: filteredCalls.length, total: archivedCalls.length })} + + ) : null} + + {renderContent()} + + + ); +} + +const getThemedStyles = (isDark: boolean) => ({ + headerBg: isDark ? '#1f2937' : '#f9fafb', + borderColor: isDark ? '#374151' : '#e5e7eb', + headerTextColor: isDark ? '#9ca3af' : '#6b7280', + rowEvenBg: isDark ? '#111827' : '#ffffff', + rowOddBg: isDark ? '#1f2937' : '#f9fafb', + textPrimary: isDark ? '#f3f4f6' : '#111827', + textSecondary: isDark ? '#d1d5db' : '#4b5563', + textMuted: isDark ? '#9ca3af' : '#6b7280', + panelBg: isDark ? '#1f2937' : '#ffffff', + activeButtonBg: isDark ? '#1e3a8a' : '#dbeafe', + linkColor: isDark ? '#60a5fa' : '#2563eb', + addColor: isDark ? '#3b82f6' : '#2563eb', +}); + +const styles = StyleSheet.create({ + toolbar: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: 8, + marginBottom: 12, + }, + searchBox: { + flexGrow: 1, + flexBasis: 240, + }, + yearSelect: { + width: 120, + }, + toolbarButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + height: 40, + paddingHorizontal: 12, + borderRadius: 8, + borderWidth: 1, + }, + toolbarButtonText: { + fontSize: 13, + fontWeight: '600', + }, + addButton: { + paddingHorizontal: 14, + }, + addButtonText: { + color: '#ffffff', + }, + filterPanel: { + borderWidth: 1, + borderRadius: 8, + padding: 12, + marginBottom: 12, + gap: 8, + }, + filterLabel: { + fontSize: 12, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + chipRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + }, + chipDot: { + marginRight: 6, + }, + dateRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + dateField: { + flexGrow: 1, + flexBasis: 160, + }, + clearFilters: { + alignSelf: 'flex-start', + paddingVertical: 4, + }, + clearFiltersText: { + fontSize: 13, + fontWeight: '600', + }, + resultCount: { + fontSize: 12, + marginBottom: 8, + }, + tableHeader: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingHorizontal: 12, + borderBottomWidth: 2, + }, + headerCell: { + fontSize: 12, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + tableRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + cellContainer: { + justifyContent: 'center', + paddingRight: 8, + }, + cellText: { + fontSize: 14, + fontWeight: '500', + }, + cellTextBold: { + fontSize: 14, + fontWeight: '700', + }, + cellTextSecondary: { + fontSize: 13, + }, + cellTextMuted: { + fontSize: 12, + marginTop: 2, + }, + priorityBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + typeBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + typeText: { + flexShrink: 1, + }, + priorityDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + cellNumber: { + width: 90, + }, + cellName: { + width: 200, + }, + cellType: { + width: 120, + }, + cellPriority: { + width: 110, + }, + cellState: { + width: 110, + }, + cellAddress: { + width: 220, + }, + cellDate: { + width: 170, + }, +}); diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index 3b7f8ba5..1dfc74a1 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -62,7 +62,7 @@ import { callLocationOf } from '@/lib/dispatch-board'; import { buildAddResourcesUpdateRequest, EMPTY_DISPATCH_SELECTION } from '@/lib/dispatch-helpers'; import { logger } from '@/lib/logging'; import { openMapsWithDirections } from '@/lib/navigation'; -import { formatDateForDisplay, getCallScheduledDispatchTime, isCallActive, isCallAwaitingScheduledDispatch, isCallPending, parseDateISOString } from '@/lib/utils'; +import { formatDateForDisplay, getCallScheduledDispatchTime, isCallActive, isCallAwaitingScheduledDispatch, isCallClosed, isCallPending, parseDateISOString } from '@/lib/utils'; import { CheckInTimerStatus } from '@/models/v4/checkIn/checkInEnums'; import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; @@ -238,7 +238,8 @@ export default function CallDetail() { // Initialize the call detail menu hook const { HeaderRightMenu, CallDetailActionSheet } = useCallDetailMenu({ onEditCall: handleEditCall, - onCloseCall: handleCloseCall, + // An archived (closed) call has nothing to close. + onCloseCall: call && isCallClosed(call.State) ? undefined : handleCloseCall, onDeleteCall: handleDeleteCall, onRescheduleCall: isScheduledPending ? handleRescheduleCall : undefined, onDispatchMore: call && isCallActive(call.State) ? () => setIsDispatchModalOpen(true) : undefined, diff --git a/src/app/call/[id].web.tsx b/src/app/call/[id].web.tsx index d2ce29b8..28d12133 100644 --- a/src/app/call/[id].web.tsx +++ b/src/app/call/[id].web.tsx @@ -57,7 +57,7 @@ import { buildAddResourcesUpdateRequest, EMPTY_DISPATCH_SELECTION } from '@/lib/ import { logger } from '@/lib/logging'; import { openMapsWithDirections } from '@/lib/navigation'; import { tintOf } from '@/lib/readable-color'; -import { formatDateForDisplay, getCallScheduledDispatchTime, isCallActive, isCallAwaitingScheduledDispatch, isCallPending, parseDateISOString } from '@/lib/utils'; +import { formatDateForDisplay, getCallScheduledDispatchTime, isCallActive, isCallAwaitingScheduledDispatch, isCallClosed, isCallPending, parseDateISOString } from '@/lib/utils'; import { CheckInTimerStatus } from '@/models/v4/checkIn/checkInEnums'; import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; @@ -191,7 +191,8 @@ export default function CallDetailWeb() { const { HeaderRightMenu, CallDetailActionSheet } = useCallDetailMenu({ onEditCall: handleEditCall, - onCloseCall: handleCloseCall, + // An archived (closed) call has nothing to close. + onCloseCall: call && isCallClosed(call.State) ? undefined : handleCloseCall, onDeleteCall: handleDeleteCall, onRescheduleCall: isScheduledPending ? handleRescheduleCall : undefined, onDispatchMore: call && isCallActive(call.State) ? () => setIsDispatchModalOpen(true) : undefined, @@ -615,7 +616,7 @@ export default function CallDetailWeb() { setIsImagesModalOpen(true)} isDark={isDark} /> setIsFilesModalOpen(true)} isDark={isDark} /> - + {!isCallClosed(call.State) ? : null} diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx index 37598a81..f9a3a9a9 100644 --- a/src/app/call/[id]/edit.tsx +++ b/src/app/call/[id]/edit.tsx @@ -10,7 +10,6 @@ import * as z from 'zod'; import { getNewCallData } from '@/api/dispatch/dispatch'; import { forwardGeocode, plusCodeLookup, what3WordsLookup } from '@/api/geocoding/geocoding'; -import { saveUdfValues } from '@/api/userDefinedFields/userDefinedFields'; import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal'; import { LinkedCallsModal } from '@/components/calls/linked-calls-modal'; import { ProtocolSelectorModal, type SelectedProtocol } from '@/components/calls/protocol-selector-modal'; @@ -31,15 +30,19 @@ 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 { useCoordinateEntry } from '@/hooks/use-coordinate-entry'; import { useDispatchMapOverlay } from '@/hooks/use-dispatch-map-overlay'; import { useNewCallFieldPolicy } from '@/hooks/use-new-call-field-policy'; +import { type CallPoint, formatCoordinate, formatStoredCoordinate } from '@/lib/call-coordinates'; import { describeCallFields, getEditCallMissingFields, getMissingCallFieldsFromError, keepHiddenEditFieldsUnchanged, toProtocolIds } from '@/lib/call-field-policy'; import { getScheduledDispatchPrefill, isDispatchTimeTooSoon, toDispatchOnUtc } from '@/lib/call-schedule'; import { getPoiDestinationOptionLabel } from '@/lib/poi-display'; +import { missingRequiredUdfFields } from '@/lib/udf/options'; import { isCallPending } from '@/lib/utils'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { type NewCallFieldKey, NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData'; import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; +import { type UdfFieldResultData } from '@/models/v4/userDefinedFields/udfFieldResultData'; import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallDetailStore } from '@/stores/calls/detail-store'; @@ -53,7 +56,9 @@ const formSchema = z.object({ note: z.string().optional(), destinationPoiId: z.string().optional(), address: z.string().optional(), - coordinates: z.string().optional(), + // As typed; checked and applied by useCoordinateEntry, which keeps latitude/longitude below in step. + latitudeText: z.string().optional(), + longitudeText: z.string().optional(), what3words: z.string().optional(), plusCode: z.string().optional(), latitude: z.number().optional(), @@ -115,10 +120,16 @@ export default function EditCall() { const [showDispatchModal, setShowDispatchModal] = useState(false); const [showAddressSelection, setShowAddressSelection] = useState(false); const [udfValues, setUdfValues] = useState([]); + // The custom fields the form shows, once their definition has loaded; null until then. + const [udfFields, setUdfFields] = useState(null); const [isAdditionalFieldsExpanded, setIsAdditionalFieldsExpanded] = useState(false); + // A required custom field opens the card by default, so nothing the call cannot be saved without is tucked away. + const hasRequiredUdfField = !!udfFields?.some((f) => f.IsRequired); + useEffect(() => { + if (hasRequiredUdfField) setIsAdditionalFieldsExpanded(true); + }, [hasRequiredUdfField]); const [isGeocodingAddress, setIsGeocodingAddress] = useState(false); const [isGeocodingPlusCode, setIsGeocodingPlusCode] = useState(false); - const [isGeocodingCoordinates, setIsGeocodingCoordinates] = useState(false); const [isGeocodingWhat3Words, setIsGeocodingWhat3Words] = useState(false); const [addressResults, setAddressResults] = useState([]); const [destinationPois, setDestinationPois] = useState([]); @@ -157,6 +168,7 @@ export default function EditCall() { formState: { errors }, setValue, reset, + watch, } = useForm({ resolver: zodResolver(formSchema), defaultValues: { @@ -165,7 +177,8 @@ export default function EditCall() { note: '', destinationPoiId: '', address: '', - coordinates: '', + latitudeText: '', + longitudeText: '', what3words: '', plusCode: '', latitude: undefined, @@ -189,6 +202,29 @@ export default function EditCall() { }, }); + // Latitude and longitude typed in separately: once both are valid and the dispatcher pauses, the pin moves + // there and a blank address is filled in for it. + const coordinateEntry = useCoordinateEntry({ + latitudeText: watch('latitudeText'), + longitudeText: watch('longitudeText'), + setText: (axis, text) => setValue(axis === 'latitude' ? 'latitudeText' : 'longitudeText', text), + location: selectedLocation, + address: watch('address'), + onLocationEntered: (location) => { + setSelectedLocation(location); + setValue('latitude', location.latitude); + setValue('longitude', location.longitude); + }, + setAddress: (address) => setValue('address', address), + // Blank inputs keep the location the call has (EditCall cannot remove one), so the pin goes back to it. + onCleared: () => { + const stored = call?.Latitude && call?.Longitude ? { latitude: parseFloat(call.Latitude), longitude: parseFloat(call.Longitude) } : null; + setSelectedLocation(stored); + setValue('latitude', stored?.latitude); + setValue('longitude', stored?.longitude); + }, + }); + useEffect(() => { fetchCallPriorities(); fetchCallTypes(); @@ -266,7 +302,8 @@ export default function EditCall() { note: call.Note || '', destinationPoiId: call.DestinationPoiId ? call.DestinationPoiId.toString() : '', address: call.Address || '', - coordinates: call.Geolocation || '', + latitudeText: formatStoredCoordinate(call.Latitude), + longitudeText: formatStoredCoordinate(call.Longitude), what3words: call.What3Words || '', // Never stored on a call: it only locates the call while it is entered. plusCode: '', @@ -334,11 +371,15 @@ export default function EditCall() { } try { - // If we have latitude and longitude, add them to the data - if (selectedLocation?.latitude && selectedLocation?.longitude) { - data.latitude = selectedLocation.latitude; - data.longitude = selectedLocation.longitude; + // Typed coordinates count as they stand, without waiting out the pause that moves the pin. Left blank, + // nothing is sent and the call keeps its location. + const typedLocation = coordinateEntry.commit(); + if (typedLocation.status === 'invalid' || typedLocation.status === 'incomplete') { + showErrorToast(t('calls.coordinates_invalid')); + return; } + data.latitude = typedLocation.status === 'valid' ? typedLocation.latitude : undefined; + data.longitude = typedLocation.status === 'valid' ? typedLocation.longitude : undefined; const destinationPoiId = data.destinationPoiId ? Number(data.destinationPoiId) : null; const addedProtocolIds = toProtocolIds(selectedProtocols); @@ -368,8 +409,12 @@ export default function EditCall() { call ); - if (missingFields.length > 0) { - showErrorToast(t('calls.required_fields_missing_edit', { fields: describeCallFields(missingFields, t) })); + // Custom fields the department requires, checked on the server too before the call is saved. + const missingUdfFields = udfFields ? missingRequiredUdfFields(udfFields, udfValues) : []; + + if (missingFields.length > 0 || missingUdfFields.length > 0) { + if (missingUdfFields.length > 0) setIsAdditionalFieldsExpanded(true); + showErrorToast(t('calls.required_fields_missing_edit', { fields: describeCallFields([...missingFields, ...missingUdfFields.map((f) => f.Label || f.Name)], t) })); return; } @@ -417,20 +462,14 @@ export default function EditCall() { dispatchUnits: data.dispatchSelection?.units, dispatchEveryone: data.dispatchSelection?.everyone, notifyCancelledEntities: data.notifyCancelledEntities, + // Saved with the call, so the server can refuse an edit that empties a required one before saving it. + udfValues: udfFields ? udfValues : undefined, }, fieldPolicy.isVisible, call ) ); - if (udfValues.length > 0 && callId) { - try { - await saveUdfValues(0, callId, udfValues); - } catch (udfError) { - console.warn('Failed to save UDF values:', udfError); - } - } - // Show success toast toast.show({ placement: 'top', @@ -460,14 +499,28 @@ export default function EditCall() { setLinkedCall({ callId: selected.CallId, number: selected.Number, name: selected.Name }); }; - const handleLocationSelected = (location: { latitude: number; longitude: number; address?: string }) => { + // Moves the call location, and shows it in the latitude and longitude inputs. + const applyLocation = (location: CallPoint) => { setSelectedLocation(location); setValue('latitude', location.latitude); setValue('longitude', location.longitude); + setValue('latitudeText', formatCoordinate(location.latitude)); + setValue('longitudeText', formatCoordinate(location.longitude)); + setShowLocationPicker(false); + }; + + // An address, what3words or plus code lookup: the place it found replaces the address, as the dispatcher asked. + const handleLocationSelected = (location: CallPoint & { address?: string }) => { + applyLocation(location); if (location.address) { setValue('address', location.address); } - setShowLocationPicker(false); + }; + + // A spot picked on the map: the address it found only fills a blank one, never one the dispatcher supplied. + const handleMapLocationSelected = (location: CallPoint & { address?: string }) => { + applyLocation(location); + void coordinateEntry.fillAddress(location, location.address); }; const handleDispatchSelection = (selection: DispatchSelection) => { @@ -937,20 +990,74 @@ export default function EditCall() { : null} {showPlusCode ? renderLookupField('plusCode', NewCallFieldKeys.PlusCode, t('calls.plus_code'), t('calls.plus_code_placeholder'), 'plus-code', isGeocodingPlusCode, handlePlusCodeSearch) : null} - {/* Map Preview — the map is how a dispatcher sets the geolocation. */} + {/* Latitude and longitude, typed in separately, and the map preview: the two ways a dispatcher sets the geolocation. */} {showGeolocation ? ( - - - {t('calls.coordinates')} - + + + + + {t('calls.latitude')} + + ( + + coordinateEntry.changeText('latitude', text)} + onBlur={onBlur} + onSubmitEditing={() => coordinateEntry.commit()} + keyboardType="numbers-and-punctuation" + /> + + )} + /> + {coordinateEntry.latitudeError ? ( + + {coordinateEntry.latitudeError} + + ) : null} + + + + {t('calls.longitude')} + + ( + + coordinateEntry.changeText('longitude', text)} + onBlur={onBlur} + onSubmitEditing={() => coordinateEntry.commit()} + keyboardType="numbers-and-punctuation" + /> + + )} + /> + {coordinateEntry.longitudeError ? ( + + {coordinateEntry.longitudeError} + + ) : null} + + + {coordinateEntry.isLookingUpAddress ? {t('calls.looking_up_address')} : null} {selectedLocation ? ( - + ) : ( )} - + ) : null} {showDestinationPoi ? ( @@ -1087,11 +1194,10 @@ export default function EditCall() { {t('calls.additional_fields', 'Additional Fields')} {isAdditionalFieldsExpanded ? : } - {isAdditionalFieldsExpanded ? ( - - - - ) : null} + {/* Kept mounted while folded: the field list decides whether the card opens, and the stored values go back with every save. */} + + + {/* Scheduled dispatch: never required on an edit; blank keeps whatever schedule the call has. A scheduled call's time can be moved but not cleared (EditCall cannot remove a schedule, so clearing would only look like it worked). */} @@ -1165,7 +1271,7 @@ export default function EditCall() { setShowLocationPicker(false)} /> diff --git a/src/app/call/[id]/edit.web.tsx b/src/app/call/[id]/edit.web.tsx index 66606b73..b7f10f14 100644 --- a/src/app/call/[id]/edit.web.tsx +++ b/src/app/call/[id]/edit.web.tsx @@ -10,7 +10,6 @@ import * as z from 'zod'; import { getNewCallData } from '@/api/dispatch/dispatch'; import { forwardGeocode, plusCodeLookup, what3WordsLookup } from '@/api/geocoding/geocoding'; -import { saveUdfValues } from '@/api/userDefinedFields/userDefinedFields'; import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal'; import { LinkedCallsModal } from '@/components/calls/linked-calls-modal'; import { ProtocolSelectorModal, type SelectedProtocol } from '@/components/calls/protocol-selector-modal'; @@ -24,15 +23,19 @@ import { Card } from '@/components/ui/card'; import { Text } from '@/components/ui/text'; import { useToast } from '@/components/ui/toast'; import { useAnalytics } from '@/hooks/use-analytics'; +import { useCoordinateEntry } from '@/hooks/use-coordinate-entry'; import { useDispatchMapOverlay } from '@/hooks/use-dispatch-map-overlay'; import { useNewCallFieldPolicy } from '@/hooks/use-new-call-field-policy'; +import { type CallPoint, formatCoordinate, formatStoredCoordinate } from '@/lib/call-coordinates'; import { describeCallFields, getEditCallMissingFields, getMissingCallFieldsFromError, keepHiddenEditFieldsUnchanged, toProtocolIds } from '@/lib/call-field-policy'; import { getScheduledDispatchPrefill, isDispatchTimeTooSoon, toDispatchOnUtc } from '@/lib/call-schedule'; import { getPoiDestinationOptionLabel } from '@/lib/poi-display'; +import { missingRequiredUdfFields } from '@/lib/udf/options'; import { isCallPending } from '@/lib/utils'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { type NewCallFieldKey, NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData'; import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; +import { type UdfFieldResultData } from '@/models/v4/userDefinedFields/udfFieldResultData'; import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallDetailStore } from '@/stores/calls/detail-store'; @@ -46,7 +49,9 @@ const formSchema = z.object({ note: z.string().optional(), destinationPoiId: z.string().optional(), address: z.string().optional(), - coordinates: z.string().optional(), + // As typed; checked and applied by useCoordinateEntry, which keeps latitude/longitude below in step. + latitudeText: z.string().optional(), + longitudeText: z.string().optional(), what3words: z.string().optional(), plusCode: z.string().optional(), latitude: z.number().optional(), @@ -235,7 +240,14 @@ export default function EditCallWeb() { const dispatchMapOverlay = useDispatchMapOverlay(selectedLocation); const [isSubmitting, setIsSubmitting] = useState(false); const [udfValues, setUdfValues] = useState([]); + // The custom fields the form shows, once their definition has loaded; null until then. + const [udfFields, setUdfFields] = useState(null); const [isAdditionalFieldsExpanded, setIsAdditionalFieldsExpanded] = useState(false); + // A required custom field opens the card by default, so nothing the call cannot be saved without is tucked away. + const hasRequiredUdfField = !!udfFields?.some((f) => f.IsRequired); + useEffect(() => { + if (hasRequiredUdfField) setIsAdditionalFieldsExpanded(true); + }, [hasRequiredUdfField]); // Protocols and a linked call this edit adds. EditCall keeps what the call already has and cannot // remove either, so these start empty rather than pre-filled from the call. const [selectedProtocols, setSelectedProtocols] = useState([]); @@ -268,7 +280,8 @@ export default function EditCallWeb() { note: '', destinationPoiId: '', address: '', - coordinates: '', + latitudeText: '', + longitudeText: '', what3words: '', plusCode: '', latitude: undefined, @@ -293,6 +306,29 @@ export default function EditCallWeb() { const watchedAddress = watch('address'); + // Latitude and longitude typed in separately: once both are valid and the dispatcher pauses, the pin moves + // there and a blank address is filled in for it. + const coordinateEntry = useCoordinateEntry({ + latitudeText: watch('latitudeText'), + longitudeText: watch('longitudeText'), + setText: (axis, text) => setValue(axis === 'latitude' ? 'latitudeText' : 'longitudeText', text, { shouldDirty: true }), + location: selectedLocation, + address: watchedAddress, + onLocationEntered: (location) => { + setSelectedLocation(location); + setValue('latitude', location.latitude, { shouldDirty: true }); + setValue('longitude', location.longitude, { shouldDirty: true }); + }, + setAddress: (address) => setValue('address', address, { shouldDirty: true }), + // Blank inputs keep the location the call has (EditCall cannot remove one), so the pin goes back to it. + onCleared: () => { + const stored = call?.Latitude && call?.Longitude ? { latitude: parseFloat(call.Latitude), longitude: parseFloat(call.Longitude) } : null; + setSelectedLocation(stored); + setValue('latitude', stored?.latitude, { shouldDirty: true }); + setValue('longitude', stored?.longitude, { shouldDirty: true }); + }, + }); + useEffect(() => { fetchCallPriorities(); fetchCallTypes(); @@ -368,7 +404,8 @@ export default function EditCallWeb() { note: call.Note || '', destinationPoiId: call.DestinationPoiId ? call.DestinationPoiId.toString() : '', address: call.Address || '', - coordinates: call.Geolocation || '', + latitudeText: formatStoredCoordinate(call.Latitude), + longitudeText: formatStoredCoordinate(call.Longitude), what3words: call.What3Words || '', // Never stored on a call: it only locates the call while it is entered. plusCode: '', @@ -466,10 +503,15 @@ export default function EditCallWeb() { try { setIsSubmitting(true); - if (selectedLocation?.latitude && selectedLocation?.longitude) { - data.latitude = selectedLocation.latitude; - data.longitude = selectedLocation.longitude; + // Typed coordinates count as they stand, without waiting out the pause that moves the pin. Left blank, + // nothing is sent and the call keeps its location. + const typedLocation = coordinateEntry.commit(); + if (typedLocation.status === 'invalid' || typedLocation.status === 'incomplete') { + showErrorToast(t('calls.coordinates_invalid')); + return; } + data.latitude = typedLocation.status === 'valid' ? typedLocation.latitude : undefined; + data.longitude = typedLocation.status === 'valid' ? typedLocation.longitude : undefined; const destinationPoiId = data.destinationPoiId ? Number(data.destinationPoiId) : null; const addedProtocolIds = toProtocolIds(selectedProtocols); @@ -499,8 +541,12 @@ export default function EditCallWeb() { call ); - if (missingFields.length > 0) { - showErrorToast(t('calls.required_fields_missing_edit', { fields: describeCallFields(missingFields, t) })); + // Custom fields the department requires, checked on the server too before the call is saved. + const missingUdfFields = udfFields ? missingRequiredUdfFields(udfFields, udfValues) : []; + + if (missingFields.length > 0 || missingUdfFields.length > 0) { + if (missingUdfFields.length > 0) setIsAdditionalFieldsExpanded(true); + showErrorToast(t('calls.required_fields_missing_edit', { fields: describeCallFields([...missingFields, ...missingUdfFields.map((f) => f.Label || f.Name)], t) })); return; } @@ -548,20 +594,14 @@ export default function EditCallWeb() { dispatchRoles: data.dispatchSelection?.roles, dispatchUnits: data.dispatchSelection?.units, dispatchEveryone: data.dispatchSelection?.everyone, + // Saved with the call, so the server can refuse an edit that empties a required one before saving it. + udfValues: udfFields ? udfValues : undefined, }, fieldPolicy.isVisible, call ) ); - if (udfValues.length > 0 && callId) { - try { - await saveUdfValues(0, callId, udfValues); - } catch (udfError) { - console.warn('Failed to save UDF values:', udfError); - } - } - toast.show({ placement: 'top', render: () => ( @@ -591,18 +631,38 @@ export default function EditCallWeb() { setLinkedCall({ callId: selected.CallId, number: selected.Number, name: selected.Name }); }, []); - const handleLocationSelected = useCallback( - (location: { latitude: number; longitude: number; address?: string }) => { + // Moves the call location, and shows it in the latitude and longitude inputs. + const applyLocation = useCallback( + (location: CallPoint) => { setSelectedLocation(location); setValue('latitude', location.latitude, { shouldDirty: true }); setValue('longitude', location.longitude, { shouldDirty: true }); + setValue('latitudeText', formatCoordinate(location.latitude), { shouldDirty: true }); + setValue('longitudeText', formatCoordinate(location.longitude), { shouldDirty: true }); + setShowLocationPicker(false); + }, + [setValue] + ); + + // An address, what3words or plus code lookup: the place it found replaces the address, as the dispatcher asked. + const handleLocationSelected = useCallback( + (location: CallPoint & { address?: string }) => { + applyLocation(location); if (location.address) { setValue('address', location.address, { shouldDirty: true }); } - setValue('coordinates', `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`, { shouldDirty: true }); - setShowLocationPicker(false); }, - [setValue] + [applyLocation, setValue] + ); + + // A spot picked on the map: the address it found only fills a blank one, never one the dispatcher supplied. + const fillAddress = coordinateEntry.fillAddress; + const handleMapLocationSelected = useCallback( + (location: CallPoint & { address?: string }) => { + applyLocation(location); + void fillAddress(location, location.address); + }, + [applyLocation, fillAddress] ); const handleDispatchSelection = useCallback( @@ -817,6 +877,13 @@ export default function EditCallWeb() { // Inputs take `required`; section titles get this asterisk. const isFieldRequired = (key: NewCallFieldKey) => fieldPolicy.isRequired(key); const requiredMark = (key: NewCallFieldKey) => (isFieldRequired(key) ? * : null); + // Enter in either coordinate input moves the pin at once, rather than after the pause. + const commitCoordinatesOnEnter = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + coordinateEntry.commit(); + } + }; // A location lookup input with its search button (what3words, plus code), as on the new-call screen. const renderLookupField = (name: 'what3words' | 'plusCode', key: NewCallFieldKey, label: string, placeholder: string, testID: string, isSearching: boolean, onSearch: (value: string) => void) => ( @@ -1036,11 +1103,10 @@ export default function EditCallWeb() { {t('calls.additional_fields', 'Additional Fields')} {isAdditionalFieldsExpanded ? : } - {isAdditionalFieldsExpanded ? ( - - - - ) : null} + {/* Kept mounted while folded: the field list decides whether the card opens, and the stored values go back with every save. */} + + + @@ -1101,28 +1167,55 @@ export default function EditCallWeb() { {showGeolocation ? ( <> - ( - + + ( + coordinateEntry.changeText('latitude', text)} + onBlur={onBlur} + error={coordinateEntry.latitudeError} + required={isFieldRequired(NewCallFieldKeys.Geolocation)} + testID="latitude-input" + onKeyDown={commitCoordinatesOnEnter} + /> + )} /> - )} - /> + + + ( + coordinateEntry.changeText('longitude', text)} + onBlur={onBlur} + error={coordinateEntry.longitudeError} + required={isFieldRequired(NewCallFieldKeys.Geolocation)} + testID="longitude-input" + onKeyDown={commitCoordinatesOnEnter} + /> + )} + /> + + + {coordinateEntry.isLookingUpAddress ? ( + {t('calls.looking_up_address')} + ) : null} {/* Map Preview — the map is how a dispatcher sets the geolocation. */} {selectedLocation ? ( - + setShowLocationPicker(true)}> {t('calls.expand_map')} @@ -1293,7 +1386,7 @@ export default function EditCallWeb() { {/* Full-screen location picker */} {showLocationPicker ? ( - setShowLocationPicker(false)} /> + setShowLocationPicker(false)} /> ) : null} diff --git a/src/app/call/new/archived.tsx b/src/app/call/new/archived.tsx new file mode 100644 index 00000000..2e5fc76b --- /dev/null +++ b/src/app/call/new/archived.tsx @@ -0,0 +1,336 @@ +import { type Href, router, Stack } from 'expo-router'; +import { ArchiveIcon } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { createArchivedCall } from '@/api/calls/calls'; +import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal'; +import { DateTimeField } from '@/components/common/date-time-field'; +import { OptionSelect } from '@/components/operations/option-select'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { FormControl, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control'; +import { Input, InputField } from '@/components/ui/input'; +import { UsersIcon } from '@/components/ui/lucide-icons'; +import { Switch } from '@/components/ui/switch'; +import { Text } from '@/components/ui/text'; +import { Textarea, TextareaInput } from '@/components/ui/textarea'; +import { useAnalytics } from '@/hooks/use-analytics'; +import { useToast } from '@/hooks/use-toast'; +import { ARCHIVED_CALL_STATES, type ArchivedCallForm, buildArchivedCallRequest } from '@/lib/archived-calls'; +import { getCallCloseErrorMessage } from '@/lib/call-close'; +import { describeCallFields, getMissingCallFieldsFromError, hasDispatchRecipients } from '@/lib/call-field-policy'; +import { useArchivedCallsStore } from '@/stores/calls/archived-store'; +import { useCallsStore } from '@/stores/calls/store'; +import { type DispatchSelection } from '@/stores/dispatch/store'; + +const EMPTY_DISPATCH: DispatchSelection = { everyone: false, users: [], groups: [], roles: [], units: [] }; + +const initialForm = (): ArchivedCallForm => ({ + name: '', + nature: '', + note: '', + priority: '', + type: '', + address: '', + latitude: '', + longitude: '', + contactName: '', + contactInfo: '', + incidentId: '', + referenceId: '', + externalId: '', + // As the web form does: now, for the dispatcher to move back to when the call happened. + loggedOn: new Date().toISOString(), + closedOn: '', + state: '1', + closedNotes: '', + recalculateCallNumbers: false, + dispatch: EMPTY_DISPATCH, +}); + +const Field: React.FC<{ label: string; children: React.ReactNode; hint?: string }> = ({ label, children, hint }) => ( + + + {label} + + {children} + {hint ? {hint} : null} + +); + +const Section: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => ( + + {title} + {children} + +); + +/** + * Add Archived Call: enter a call that already happened. It is saved closed with the time it was logged; who it + * went to is recorded on the call and nobody is notified. + */ +export default function NewArchivedCall() { + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + const insets = useSafeAreaInsets(); + const toast = useToast(); + const { trackEvent } = useAnalytics(); + const callPriorities = useCallsStore((s) => s.callPriorities); + const callTypes = useCallsStore((s) => s.callTypes); + const fetchCallPriorities = useCallsStore((s) => s.fetchCallPriorities); + const fetchCallTypes = useCallsStore((s) => s.fetchCallTypes); + const [form, setForm] = useState(initialForm); + const [showDispatchModal, setShowDispatchModal] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + fetchCallPriorities(); + fetchCallTypes(); + }, [fetchCallPriorities, fetchCallTypes]); + + // The department's default priority, once the priorities are in, unless one was already picked. + useEffect(() => { + if (form.priority === '' && callPriorities.length > 0) { + const defaultPriority = callPriorities.find((p) => p.IsDefault) ?? callPriorities[0]; + setForm((current) => (current.priority === '' ? { ...current, priority: String(defaultPriority.Id) } : current)); + } + }, [callPriorities, form.priority]); + + const update = (key: K, value: ArchivedCallForm[K]) => setForm((current) => ({ ...current, [key]: value })); + + const priorityOptions = useMemo(() => callPriorities.map((p) => ({ value: String(p.Id), label: p.Name })), [callPriorities]); + const typeOptions = useMemo(() => [{ value: '', label: t('archived_calls.no_type') }, ...callTypes.map((type) => ({ value: type.Name, label: type.Name }))], [callTypes, t]); + const stateOptions = useMemo(() => ARCHIVED_CALL_STATES.map((s) => ({ value: String(s.value), label: t(s.labelKey) })), [t]); + + const dispatchSummary = form.dispatch.everyone + ? t('archived_calls.dispatched_everyone') + : hasDispatchRecipients(form.dispatch) + ? t('archived_calls.dispatched_summary', { users: form.dispatch.users.length, groups: form.dispatch.groups.length, roles: form.dispatch.roles.length, units: form.dispatch.units.length }) + : t('archived_calls.dispatched_none'); + + const handleSubmit = async () => { + const built = buildArchivedCallRequest(form); + if ('errorKey' in built) { + toast.error(t(built.errorKey)); + return; + } + + setIsSubmitting(true); + try { + await createArchivedCall(built.request); + + trackEvent('archived_call_created', { + state: built.request.state, + hasDispatches: hasDispatchRecipients(form.dispatch), + recalculateCallNumbers: built.request.recalculateCallNumbers === true, + }); + toast.success(t('archived_calls.save_success')); + + // The list opens on the year the call was logged in, so the new call is in front of the dispatcher. + useArchivedCallsStore.getState().showYear(String(new Date(built.request.loggedOnUtc).getFullYear())); + if (router.canGoBack()) { + router.back(); + } else { + router.replace('/archived-calls' as Href); + } + } catch (error) { + // The department's new-call field policy covers archived calls too; the server names what is missing. + const missingOnServer = getMissingCallFieldsFromError(error); + toast.error(missingOnServer ? t('calls.required_fields_missing', { fields: describeCallFields(missingOnServer, t) }) : (getCallCloseErrorMessage(error) ?? t('archived_calls.save_error'))); + } finally { + setIsSubmitting(false); + } + }; + + const iconColor = colorScheme === 'dark' ? '#e5e7eb' : '#374151'; + + return ( + <> + + + + + + + + + {t('archived_calls.add_title')} + + {t('archived_calls.add_description')} + +
+ + + update('name', value)} placeholder={t('calls.name_placeholder')} testID="archived-call-name" /> + + + + + + + update('priority', value)} + testID="archived-call-priority" + /> + + + update('type', value)} testID="archived-call-type" /> + + + + +
+ +
+ + update('loggedOn', value)} label={t('archived_calls.logged_on')} mode="datetime" clearable={false} testID="archived-call-logged-on" /> + + + update('closedOn', value)} label={t('archived_calls.closed_on')} mode="datetime" testID="archived-call-closed-on" /> + + + update('state', value)} testID="archived-call-state" /> + + + + +
+ +
+ + + update('address', value)} placeholder={t('calls.address_placeholder')} testID="archived-call-address" /> + + + + + + + update('latitude', value)} placeholder="39.122639" keyboardType="numbers-and-punctuation" testID="archived-call-latitude" /> + + + + + + + update('longitude', value)} placeholder="-119.758773" keyboardType="numbers-and-punctuation" testID="archived-call-longitude" /> + + + + +
+ +
+ + + + + update('contactName', value)} placeholder={t('calls.contact_name_placeholder')} testID="archived-call-contact-name" /> + + + + + + + update('contactInfo', value)} placeholder={t('calls.contact_info_placeholder')} testID="archived-call-contact-info" /> + + + + + + + + + update('incidentId', value)} testID="archived-call-incident-id" /> + + + + + + + update('referenceId', value)} testID="archived-call-reference-id" /> + + + + + + + update('externalId', value)} testID="archived-call-external-id" /> + + + + +
+ +
+ {t('archived_calls.dispatched_hint')} + + + {dispatchSummary} + +
+ +
+ + + {t('archived_calls.recalculate_numbers')} + {t('archived_calls.recalculate_numbers_hint')} + + update('recalculateCallNumbers', value)} + accessibilityLabel={t('archived_calls.recalculate_numbers')} + testID="archived-call-recalculate" + /> + +
+ + + + + +
+
+
+
+ + {/* Who it went to: recorded on the call only, nobody is notified. */} + setShowDispatchModal(false)} onConfirm={(selection) => update('dispatch', selection)} initialSelection={form.dispatch} /> + + ); +} diff --git a/src/app/call/new/index.tsx b/src/app/call/new/index.tsx index be35cdb1..dc346e1f 100644 --- a/src/app/call/new/index.tsx +++ b/src/app/call/new/index.tsx @@ -11,8 +11,7 @@ import * as z from 'zod'; import { createCall } from '@/api/calls/calls'; import { getNewCallData } from '@/api/dispatch/dispatch'; -import { forwardGeocode, plusCodeLookup, reverseGeocode, what3WordsLookup } from '@/api/geocoding/geocoding'; -import { saveUdfValues } from '@/api/userDefinedFields/userDefinedFields'; +import { forwardGeocode, plusCodeLookup, what3WordsLookup } from '@/api/geocoding/geocoding'; import { CallTemplatesModal, type TemplateSelection } from '@/components/calls/call-templates-modal'; import { ContactPickerModal } from '@/components/calls/contact-picker-modal'; import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal'; @@ -39,17 +38,21 @@ import { Switch } from '@/components/ui/switch'; import { Text } from '@/components/ui/text'; import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { useAnalytics } from '@/hooks/use-analytics'; +import { useCoordinateEntry } from '@/hooks/use-coordinate-entry'; import { useDispatchMapOverlay } from '@/hooks/use-dispatch-map-overlay'; import { useNewCallFieldPolicy } from '@/hooks/use-new-call-field-policy'; import { useToast } from '@/hooks/use-toast'; -import { describeCallFields, getMissingCallFieldsFromError, hasDispatchRecipients, toProtocolIds } from '@/lib/call-field-policy'; +import { type CallPoint, formatCoordinate } from '@/lib/call-coordinates'; +import { describeCallFields, getMissingCallFieldsFromError, hasDispatchRecipients, sectionsHolding, toProtocolIds } from '@/lib/call-field-policy'; import { formatGeolocation } from '@/lib/call-geolocation'; import { isDispatchTimeTooSoon, toDispatchOnUtc } from '@/lib/call-schedule'; import { getPoiDestinationOptionLabel } from '@/lib/poi-display'; +import { missingRequiredUdfFields } from '@/lib/udf/options'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { type NewCallFieldKey, NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData'; import { type ContactResultData } from '@/models/v4/contacts/contactResultData'; import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; +import { type UdfFieldResultData } from '@/models/v4/userDefinedFields/udfFieldResultData'; import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; @@ -62,7 +65,9 @@ const formSchema = z.object({ note: z.string().optional(), destinationPoiId: z.string().optional(), address: z.string().optional(), - coordinates: z.string().optional(), + // As typed; checked and applied by useCoordinateEntry, which keeps latitude/longitude below in step. + latitudeText: z.string().optional(), + longitudeText: z.string().optional(), what3words: z.string().optional(), plusCode: z.string().optional(), latitude: z.number().optional(), @@ -91,6 +96,18 @@ type FormValues = z.infer; const NO_DESTINATION_VALUE = '__none__'; +// The collapsible cards that hold a policy-controlled field, so a card with a required one opens by default. +const NEW_CALL_SECTION_FIELDS = { + note: [NewCallFieldKeys.Note], + location: [NewCallFieldKeys.Address, NewCallFieldKeys.Geolocation, NewCallFieldKeys.What3Words, NewCallFieldKeys.PlusCode, NewCallFieldKeys.DestinationPoi], + contact: [NewCallFieldKeys.ContactName, NewCallFieldKeys.ContactInfo, NewCallFieldKeys.ExternalId, NewCallFieldKeys.IncidentId, NewCallFieldKeys.ReferenceId], + protocols: [NewCallFieldKeys.Protocols], + linkedCall: [NewCallFieldKeys.LinkedCall], + dispatch: [NewCallFieldKeys.DispatchList], +}; + +type NewCallSection = keyof typeof NEW_CALL_SECTION_FIELDS | 'additionalFields'; + // Google Maps Geocoding API response types interface GeocodingResult { formatted_address: string; @@ -179,7 +196,6 @@ export default function NewCall() { const [isGeocodingAddress, setIsGeocodingAddress] = useState(false); const [isGeocodingPlusCode, setIsGeocodingPlusCode] = useState(false); - const [isGeocodingCoordinates, setIsGeocodingCoordinates] = useState(false); const [isGeocodingWhat3Words, setIsGeocodingWhat3Words] = useState(false); const [addressResults, setAddressResults] = useState([]); const [dispatchSelection, setDispatchSelection] = useState({ @@ -193,6 +209,25 @@ export default function NewCall() { // The department's new-call field policy: hides fields it does not use and blocks submission // until the ones it marked required have values. Unconfigured departments see the stock form. const fieldPolicy = useNewCallFieldPolicy(); + // The custom fields the form shows, once their definition has loaded; null until then. + const [udfFields, setUdfFields] = useState(null); + + const openSections = (sections: readonly NewCallSection[]) => { + if (sections.length === 0) return; + setSectionsExpanded((prev) => sections.reduce((next, section) => ({ ...next, [section]: true }), prev)); + }; + + // A card holding a field the department requires opens by default, so nothing the call cannot be saved without is + // tucked away. Once per policy or field list, so the dispatcher can still fold a card away afterwards. + const requiredSectionsKey = fieldPolicy.isLoaded ? sectionsHolding(NEW_CALL_SECTION_FIELDS, fieldPolicy.isRequired).join(',') : ''; + const hasRequiredUdfField = !!udfFields?.some((f) => f.IsRequired); + useEffect(() => { + const sections = (requiredSectionsKey ? requiredSectionsKey.split(',') : []) as NewCallSection[]; + const toOpen = hasRequiredUdfField ? [...sections, 'additionalFields' as const] : sections; + if (toOpen.length === 0) return; + setSectionsExpanded((prev) => toOpen.reduce((next, section) => ({ ...next, [section]: true }), prev)); + }, [requiredSectionsKey, hasRequiredUdfField]); + const [selectedLocation, setSelectedLocation] = useState<{ latitude: number; longitude: number; @@ -215,7 +250,8 @@ export default function NewCall() { note: '', destinationPoiId: '', address: '', - coordinates: '', + latitudeText: '', + longitudeText: '', what3words: '', plusCode: '', latitude: undefined, @@ -238,6 +274,28 @@ export default function NewCall() { }, }); + // Latitude and longitude typed in separately: once both are valid and the dispatcher pauses, the pin moves + // there and a blank address is filled in for it. + const coordinateEntry = useCoordinateEntry({ + latitudeText: watch('latitudeText'), + longitudeText: watch('longitudeText'), + setText: (axis, text) => setValue(axis === 'latitude' ? 'latitudeText' : 'longitudeText', text), + location: selectedLocation, + address: watch('address'), + onLocationEntered: (location) => { + setSelectedLocation(location); + setValue('latitude', location.latitude); + setValue('longitude', location.longitude); + }, + setAddress: (address) => setValue('address', address), + // Emptying both inputs takes the location off the call. + onCleared: () => { + setSelectedLocation(null); + setValue('latitude', undefined); + setValue('longitude', undefined); + }, + }); + useEffect(() => { fetchCallPriorities(); fetchCallTypes(); @@ -285,6 +343,16 @@ export default function NewCall() { return; } + // Typed coordinates count as they stand, without waiting out the pause that moves the pin. + const typedLocation = coordinateEntry.commit(); + if (typedLocation.status === 'invalid' || typedLocation.status === 'incomplete') { + openSections(['location']); + toast.error(t('calls.coordinates_invalid')); + return; + } + data.latitude = typedLocation.status === 'valid' ? typedLocation.latitude : undefined; + data.longitude = typedLocation.status === 'valid' ? typedLocation.longitude : undefined; + // The same value createCall sends, so "required" here and on the server agree on what counts // as a location (a single zero coordinate does; a blank pair or 0,0 does not). const geolocation = formatGeolocation(data.latitude, data.longitude); @@ -315,9 +383,13 @@ export default function NewCall() { [NewCallFieldKeys.DispatchList]: hasDispatchRecipients(dispatchSelection), }) .filter((key) => key !== NewCallFieldKeys.IndoorLocation && !(isPending && (key === NewCallFieldKeys.DispatchOn || key === NewCallFieldKeys.DispatchList))); + // Custom fields the department requires, checked on the server too before the call is created. + const missingUdfFields = udfFields ? missingRequiredUdfFields(udfFields, udfValues) : []; - if (missingFields.length > 0) { - toast.error(t('calls.required_fields_missing', { fields: describeCallFields(missingFields, t) })); + if (missingFields.length > 0 || missingUdfFields.length > 0) { + // Open the cards holding what is missing, so the dispatcher lands on it. + openSections([...sectionsHolding(NEW_CALL_SECTION_FIELDS, (key) => missingFields.includes(key)), ...(missingUdfFields.length > 0 ? (['additionalFields'] as const) : [])]); + toast.error(t('calls.required_fields_missing', { fields: describeCallFields([...missingFields, ...missingUdfFields.map((f) => f.Label || f.Name)], t) })); return; } @@ -327,12 +399,6 @@ export default function NewCall() { return; } - // If we have latitude and longitude, add them to the data - if (selectedLocation?.latitude && selectedLocation?.longitude) { - data.latitude = selectedLocation.latitude; - data.longitude = selectedLocation.longitude; - } - // Validate priority and type before proceeding const priority = callPriorities.find((p) => p.Name === data.priority); const type = callTypes.find((t) => t.Name === data.type); @@ -349,7 +415,7 @@ export default function NewCall() { console.log('Creating new call with data:', data); - const response = await createCall({ + await createCall({ name: data.name, nature: data.nature, priority: priority.Id, @@ -380,16 +446,10 @@ export default function NewCall() { isPending, // Conditional run cards: the response codes and decision outcome the dispatcher reviewed (nothing otherwise). ...newCallRunCardSaveFields(data.dispatchSelection?.units), + // Saved with the call, so the server can refuse a call missing a required one before creating it. + udfValues: udfFields ? udfValues : undefined, }); - if (udfValues.length > 0 && response?.Id) { - try { - await saveUdfValues(0, response.Id, udfValues); - } catch (udfError) { - console.warn('Failed to save UDF values:', udfError); - } - } - if (isPending) { // Saved for later: show the queue it now waits in. toast.success(t('calls.pending_saved')); @@ -412,21 +472,28 @@ export default function NewCall() { } }; - // Handle location selection from the full-screen picker - const handleLocationSelected = (location: { latitude: number; longitude: number; address?: string }) => { + // Moves the call location, and shows it in the latitude and longitude inputs. + const applyLocation = (location: CallPoint) => { setSelectedLocation(location); setShowLocationPicker(false); - - // Update form values setValue('latitude', location.latitude); setValue('longitude', location.longitude); + setValue('latitudeText', formatCoordinate(location.latitude)); + setValue('longitudeText', formatCoordinate(location.longitude)); + }; + // An address, what3words or plus code lookup: the place it found replaces the address, as the dispatcher asked. + const handleLocationSelected = (location: CallPoint & { address?: string }) => { + applyLocation(location); if (location.address) { setValue('address', location.address); } + }; - // Format coordinates as string - setValue('coordinates', `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`); + // A spot picked on the map: the address it found only fills a blank one, never one the dispatcher supplied. + const handleMapLocationSelected = (location: CallPoint & { address?: string }) => { + applyLocation(location); + void coordinateEntry.fillAddress(location, location.address); }; // Handle dispatch selection @@ -671,92 +738,6 @@ export default function NewCall() { } }; - /** - * Handles coordinates search using Google Maps Reverse Geocoding API - * - * Features: - * - Validates and parses coordinates string (lat,lng format) - * - Uses Google Maps API key from CoreStore configuration - * - Handles API errors gracefully with user-friendly messages - * - Shows loading state during API call - * - Updates address field and map location - * - Supports various coordinate formats (decimal degrees) - * - * @param coordinates - The coordinates string to reverse geocode (e.g., "40.7128, -74.0060") - */ - const handleCoordinatesSearch = async (coordinates: string) => { - if (!coordinates.trim()) { - toast.warning(t('calls.coordinates_required')); - return; - } - - // Parse coordinates - expect format like "40.7128, -74.0060" or "40.7128,-74.0060" - const coordRegex = /^(-?\d+\.?\d*),?\s*(-?\d+\.?\d*)$/; - const match = coordinates.trim().match(coordRegex); - - if (!match) { - toast.warning(t('calls.coordinates_invalid_format')); - return; - } - - const latitude = parseFloat(match[1]); - const longitude = parseFloat(match[2]); - - // Validate coordinate ranges - if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) { - toast.warning(t('calls.coordinates_out_of_range')); - return; - } - - setIsGeocodingCoordinates(true); - try { - // Proxied through the Resgrid API — see src/api/geocoding/geocoding.ts for why. - const lookup = await reverseGeocode(latitude, longitude); - - if (lookup.address) { - const newLocation = { - latitude, - longitude, - address: lookup.address, - }; - - // Update the selected location and form values - handleLocationSelected(newLocation); - - // Show success toast - toast.success(t('calls.coordinates_found')); - } else { - // Even if no address found, still set the location on the map - const newLocation = { - latitude, - longitude, - address: undefined, - }; - - handleLocationSelected(newLocation); - - // Show info toast - toast.info(t('calls.coordinates_no_address')); - } - } catch (error) { - console.error('Error reverse geocoding coordinates:', error); - - // Even if geocoding fails, still set the location on the map - const newLocation = { - latitude, - longitude, - address: undefined, - }; - - handleLocationSelected(newLocation); - - // Show warning toast - toast.warning(t('calls.coordinates_geocoding_error')); - } finally { - setIsGeocodingCoordinates(false); - } - }; - if (isLoading) { return ; } @@ -1025,36 +1006,67 @@ export default function NewCall() { ) : null} - {/* GPS Coordinates Field */} + {/* Latitude and longitude, typed in separately */} {showGeolocation ? ( - - - {t('calls.coordinates')} - - ( - - + + + + + {t('calls.latitude')} + + ( - + coordinateEntry.changeText('latitude', text)} + onBlur={onBlur} + onSubmitEditing={() => coordinateEntry.commit()} + keyboardType="numbers-and-punctuation" + /> - - - - )} - /> - + )} + /> + {coordinateEntry.latitudeError ? ( + + {coordinateEntry.latitudeError} + + ) : null} + + + + {t('calls.longitude')} + + ( + + coordinateEntry.changeText('longitude', text)} + onBlur={onBlur} + onSubmitEditing={() => coordinateEntry.commit()} + keyboardType="numbers-and-punctuation" + /> + + )} + /> + {coordinateEntry.longitudeError ? ( + + {coordinateEntry.longitudeError} + + ) : null} + + + {coordinateEntry.isLookingUpAddress ? {t('calls.looking_up_address')} : null} + ) : null} {/* what3words Field */} @@ -1111,7 +1123,7 @@ export default function NewCall() { {showGeolocation ? ( {selectedLocation ? ( - + ) : ( - + {/* Taps commit as they happen; only a pin the map placed itself (the dispatcher's own position) waits for this. */} + {currentLocation && (!initialLocation || spotKey(currentLocation) !== spotKey(initialLocation)) ? ( + + + + ) : null} ); }; diff --git a/src/components/maps/location-picker.web.tsx b/src/components/maps/location-picker.web.tsx index 188cc060..c7f4b767 100644 --- a/src/components/maps/location-picker.web.tsx +++ b/src/components/maps/location-picker.web.tsx @@ -20,6 +20,7 @@ interface LocationPickerProps { latitude: number; longitude: number; }; + /** Called with every pin move: a click on the map is the new call location, with no confirm step to forget. */ onLocationSelected: (location: { latitude: number; longitude: number; address?: string }) => void; height?: number; /** @@ -29,6 +30,8 @@ interface LocationPickerProps { overlay?: DispatchMapOverlay | null; } +const spotKey = (location: { latitude: number; longitude: number }) => `${location.latitude.toFixed(5)},${location.longitude.toFixed(5)}`; + const LocationPicker: React.FC = ({ initialLocation, onLocationSelected, height = 200, overlay }) => { const { t } = useTranslation(); const mapContainer = useRef(null); @@ -54,6 +57,13 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca const popupRef = useRef(null); // The call location the map was last framed for, so a refreshed board does not yank the view the dispatcher moved. const framedForRef = useRef(null); + // The spot the pin is on, so the form handing back a location the pin already shows does not move the map. + const pinKeyRef = useRef(initialLocation ? spotKey(initialLocation) : null); + // Read by the map's click handler, which is registered once when the map is built. + const onLocationSelectedRef = useRef(onLocationSelected); + useEffect(() => { + onLocationSelectedRef.current = onLocationSelected; + }, [onLocationSelected]); // Inject Mapbox GL CSS useEffect(() => { @@ -179,6 +189,12 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca } else if (map.current) { marker.current = new mapboxgl.Marker({ color: '#FF0000' }).setLngLat([e.lngLat.lng, e.lngLat.lat]).addTo(map.current); } + + // The dispatcher is already looking at the spot they clicked, so the board for it does not reframe the map. + pinKeyRef.current = spotKey(newLocation); + framedForRef.current = spotKey(newLocation); + // The pin is the call location: the form, and the recipient picker's distances and ETAs, follow it at once. + onLocationSelectedRef.current(newLocation); }); // If no initial location, get user location @@ -197,6 +213,26 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // The call location set elsewhere (an address or coordinate search, the full-screen map) moves the pin here too. + const initialLatitude = initialLocation?.latitude; + const initialLongitude = initialLocation?.longitude; + useEffect(() => { + if (typeof initialLatitude !== 'number' || typeof initialLongitude !== 'number') return; + const next = { latitude: initialLatitude, longitude: initialLongitude }; + // Where the pin already is: the location the map was built with, or a click here coming back from the form. + if (pinKeyRef.current === spotKey(next)) return; + pinKeyRef.current = spotKey(next); + setCurrentLocation(next); + + if (!map.current) return; + if (marker.current) { + marker.current.setLngLat([initialLongitude, initialLatitude]); + } else { + marker.current = new mapboxgl.Marker({ color: '#FF0000' }).setLngLat([initialLongitude, initialLatitude]).addTo(map.current); + } + map.current.flyTo({ center: [initialLongitude, initialLatitude] }); + }, [initialLatitude, initialLongitude]); + // Switch the base map when the department style changes; the DOM marker survives setStyle. useEffect(() => { if (!map.current || appliedMapStyleRef.current === mapStyle) return; @@ -254,11 +290,14 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca
) : null} - - - + {/* Clicks commit as they happen; only a pin the map placed itself (the dispatcher's own position) waits for this. */} + {currentLocation && (!initialLocation || spotKey(currentLocation) !== spotKey(initialLocation)) ? ( + + + + ) : null}
); }; diff --git a/src/components/maps/map-focus-banner.tsx b/src/components/maps/map-focus-banner.tsx new file mode 100644 index 00000000..45f65f6c --- /dev/null +++ b/src/components/maps/map-focus-banner.tsx @@ -0,0 +1,99 @@ +import { LocateFixed } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Icon } from '@/components/ui/icon'; +import { type MapFocus, type MapFocusPoints } from '@/lib/unit-map-focus'; + +interface MapFocusBannerProps { + focus: MapFocus; + located: MapFocusPoints; + /** Frames the unit and its crew again after the dispatcher moved the map. */ + onRecenter: () => void; +} + +/** The selected unit the map is framed on, and how much of it has a position (the unit, and how many of its crew). */ +export function MapFocusBanner({ focus, located, onRecenter }: MapFocusBannerProps) { + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === 'dark'; + const hasPositions = located.points.length > 0; + + const details = hasPositions + ? [ + located.unitLocated ? null : t('dispatch.unit_map_focus_no_unit_position'), + located.crewTotal === 0 ? t('dispatch.unit_map_focus_no_crew') : t('dispatch.unit_map_focus_crew_located', { located: located.crewLocated, total: located.crewTotal }), + ].filter((detail): detail is string => !!detail) + : [t('dispatch.unit_map_focus_no_positions')]; + + return ( + + + + {focus.label} + + + {details.join(' · ')} + + + {hasPositions ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + banner: { + position: 'absolute', + top: 8, + left: 8, + maxWidth: 280, + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 6, + paddingLeft: 10, + paddingRight: 4, + borderRadius: 8, + borderWidth: 1, + zIndex: 10, + }, + bannerLight: { + backgroundColor: 'rgba(255, 255, 255, 0.94)', + borderColor: '#e5e7eb', + }, + bannerDark: { + backgroundColor: 'rgba(17, 24, 39, 0.92)', + borderColor: '#374151', + }, + text: { + flexShrink: 1, + }, + title: { + fontSize: 13, + fontWeight: '600', + }, + titleLight: { + color: '#111827', + }, + titleDark: { + color: '#f9fafb', + }, + detail: { + fontSize: 11, + }, + detailLight: { + color: '#4b5563', + }, + detailDark: { + color: '#d1d5db', + }, + recenter: { + marginLeft: 6, + padding: 6, + }, +}); diff --git a/src/components/maps/unified-map-view.tsx b/src/components/maps/unified-map-view.tsx index 7a4f9a7d..97cdc15f 100644 --- a/src/components/maps/unified-map-view.tsx +++ b/src/components/maps/unified-map-view.tsx @@ -8,11 +8,13 @@ import { useMapLiveLocations } from '@/hooks/use-map-live-locations'; import { logger } from '@/lib/logging'; import { getDepartmentMapCenter } from '@/lib/map-center'; import { useDepartmentMapStyle } from '@/lib/map-style'; +import { boundsForFocusPoints, focusFrameKey, hasPointLeftView, type MapFocus, resolveMapFocusPoints, visibleBoundsFromCorners } from '@/lib/unit-map-focus'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { type GetMapLayersData } from '@/models/v4/mapping/getMapLayersResultData'; import { useLocationStore } from '@/stores/app/location-store'; import { useSignalRStore } from '@/stores/signalr/signalr-store'; +import { MapFocusBanner } from './map-focus-banner'; import MapPins from './map-pins'; interface UnifiedMapViewProps { @@ -34,6 +36,11 @@ interface UnifiedMapViewProps { style?: any; /** Test ID for testing */ testID?: string; + /** + * A unit and its crew to keep framed (the dispatcher's selected unit). The view frames them when the focus or the + * set of them with a position changes, and again when one on screen moves off it; null leaves the camera alone. + */ + focus?: MapFocus | null; } /** @@ -50,6 +57,7 @@ const UnifiedMapViewComponent: React.FC = ({ interactive = true, style, testID = 'unified-map-view', + focus = null, }) => { const mapRef = useRef(null); const cameraRef = useRef(null); @@ -66,6 +74,12 @@ const UnifiedMapViewComponent: React.FC = ({ // Use external pins if provided, otherwise use internal pins const mapPins = externalPins ?? internalPins; + // Read by the initial centering, which must not override a selected unit's frame. + const focusRef = useRef(focus); + useEffect(() => { + focusRef.current = focus; + }, [focus]); + // 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); @@ -104,6 +118,11 @@ const UnifiedMapViewComponent: React.FC = ({ const markers = mapDataAndMarkers.Data.MapMakerInfos; setInternalPins(applyToFetchedPins(markers, fetchStartedAt)); + // A unit selected before the pins arrived owns the camera: the focus effect frames it on these pins. + if (focusRef.current) { + return; + } + // Center map on the data center if provided if (mapDataAndMarkers.Data.CenterLat && mapDataAndMarkers.Data.CenterLon && cameraRef.current) { const centerLat = parseFloat(mapDataAndMarkers.Data.CenterLat); @@ -280,6 +299,55 @@ const UnifiedMapViewComponent: React.FC = ({ }); }, [visibleLayers]); + // Where the focused unit and its crew are now: the pins already carry the live positions. + const focusPoints = useMemo(() => (focus ? resolveMapFocusPoints(mapPins, focus) : null), [focus, mapPins]); + const framedKeyRef = useRef(null); + const framedPositionsRef = useRef>(new Map()); + const [recenterCount, setRecenterCount] = useState(0); + const handleRecenter = useCallback(() => setRecenterCount((count) => count + 1), []); + + // Frame the selected unit and its crew. A new unit, a crew change, someone starting or stopping reporting, or + // "re-center" frames again; a position moving only does when it takes someone who was on screen off it. + useEffect(() => { + if (!focus || !focusPoints) { + framedKeyRef.current = null; + framedPositionsRef.current = new Map(); + return; + } + + if (!isMapReady || !cameraRef.current) return; + + const bounds = boundsForFocusPoints(focusPoints.points.map((point) => point.coordinate)); + // Nobody has a position: the camera stays where it is and the banner says so. + if (!bounds) return; + + const frameKey = `${focusFrameKey(focus, focusPoints)}#${recenterCount}`; + const previous = framedPositionsRef.current; + framedPositionsRef.current = new Map(focusPoints.points.map((point) => [point.pinId, point.coordinate])); + + const frame = () => { + framedKeyRef.current = frameKey; + cameraRef.current?.fitBounds(bounds.ne, bounds.sw, [48, 48, 48, 48], 600); + }; + + if (framedKeyRef.current !== frameKey) { + frame(); + return; + } + + // Same crew, same people reporting: only follow someone who just left the screen. Not cancelled by the next + // update, which compares against these positions and so could never see the move off screen; a frame taken (or + // the focus cleared) in the meantime makes this one stand down. + void (async () => { + try { + const visible = visibleBoundsFromCorners(await mapRef.current?.getVisibleBounds()); + if (visible && framedKeyRef.current === frameKey && hasPointLeftView(previous, focusPoints.points, visible)) frame(); + } catch (error) { + logger.debug({ message: 'Unable to read the visible map bounds', context: { error } }); + } + })(); + }, [focus, focusPoints, isMapReady, recenterCount]); + const handleMapReady = useCallback(() => { setIsMapReady(true); onMapReady?.(); @@ -314,6 +382,7 @@ const UnifiedMapViewComponent: React.FC = ({ {/* Map pins */} + {focus && focusPoints ? : null} ); }; diff --git a/src/components/maps/unified-map-view.web.tsx b/src/components/maps/unified-map-view.web.tsx index aa564b85..5b53fb8d 100644 --- a/src/components/maps/unified-map-view.web.tsx +++ b/src/components/maps/unified-map-view.web.tsx @@ -12,10 +12,13 @@ import { hasValidMapCoordinates } from '@/lib/map-markers'; import { buildMapPinPopupHtml, createMapMarkerElement, getMapMarkerPopupOffset, MAP_MARKER_ANCHOR } from '@/lib/map-markers-web'; import { useDepartmentMapStyle } from '@/lib/map-style'; import { applyMapboxGlAccessToken } from '@/lib/mapbox-gl-token-web'; +import { boundsForFocusPoints, FOCUS_MAX_ZOOM, focusFrameKey, hasPointLeftView, type MapFocus, resolveMapFocusPoints } from '@/lib/unit-map-focus'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { type GetMapLayersData } from '@/models/v4/mapping/getMapLayersResultData'; import { useLocationStore } from '@/stores/app/location-store'; +import { MapFocusBanner } from './map-focus-banner'; + // Mapbox GL CSS needs to be injected for web const MAPBOX_GL_CSS_URL = 'https://api.mapbox.com/mapbox-gl-js/v3.15.0/mapbox-gl.css'; @@ -47,6 +50,11 @@ interface UnifiedMapViewProps { style?: any; /** Test ID for testing */ testID?: string; + /** + * A unit and its crew to keep framed (the dispatcher's selected unit). The view frames them when the focus or the + * set of them with a position changes, and again when one on screen moves off it; null leaves the camera alone. + */ + focus?: MapFocus | null; } // Helper function to calculate center from markers @@ -77,6 +85,7 @@ const UnifiedMapViewComponent: React.FC = ({ interactive = true, style, testID = 'unified-map-view', + focus = null, }) => { const { colorScheme } = useColorScheme(); const mapContainer = useRef(null); @@ -93,6 +102,12 @@ const UnifiedMapViewComponent: React.FC = ({ // Use external pins if provided, otherwise use internal pins const mapPins = externalPins ?? internalPins; + // Read by the first-load centering, which must not override a selected unit's frame. + const focusRef = useRef(focus); + useEffect(() => { + focusRef.current = focus; + }, [focus]); + // 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); @@ -217,6 +232,11 @@ const UnifiedMapViewComponent: React.FC = ({ const markers = mapDataAndMarkers.Data.MapMakerInfos; setInternalPins(applyToFetchedPins(markers, fetchStartedAt)); + // A unit selected before the pins arrived owns the camera: the focus effect frames it on these pins. + if (!hasCenteredOnDataRef.current && focusRef.current) { + hasCenteredOnDataRef.current = true; + } + if (!hasCenteredOnDataRef.current) { hasCenteredOnDataRef.current = true; @@ -357,6 +377,43 @@ const UnifiedMapViewComponent: React.FC = ({ }); }, [mapPins, isMapReady, colorScheme]); + // Where the focused unit and its crew are now: the pins already carry the live positions. + const focusPoints = useMemo(() => (focus ? resolveMapFocusPoints(mapPins, focus) : null), [focus, mapPins]); + const framedKeyRef = useRef(null); + const framedPositionsRef = useRef>(new Map()); + const [recenterCount, setRecenterCount] = useState(0); + const handleRecenter = useCallback(() => setRecenterCount((count) => count + 1), []); + + // Frame the selected unit and its crew. A new unit, a crew change, someone starting or stopping reporting, or + // "re-center" frames again; a position moving only does when it takes someone who was on screen off it. + useEffect(() => { + const instance = map.current; + + if (!focus || !focusPoints) { + framedKeyRef.current = null; + framedPositionsRef.current = new Map(); + return; + } + + if (!instance || !isMapReady) return; + + const bounds = boundsForFocusPoints(focusPoints.points.map((point) => point.coordinate)); + // Nobody has a position: the camera stays where it is and the banner says so. + if (!bounds) return; + + const frameKey = `${focusFrameKey(focus, focusPoints)}#${recenterCount}`; + const previous = framedPositionsRef.current; + framedPositionsRef.current = new Map(focusPoints.points.map((point) => [point.pinId, point.coordinate])); + + if (framedKeyRef.current === frameKey) { + const visible = instance.getBounds(); + if (!visible || !hasPointLeftView(previous, focusPoints.points, { contains: (coordinate) => visible.contains(coordinate) })) return; + } + + framedKeyRef.current = frameKey; + instance.fitBounds([bounds.sw, bounds.ne], { padding: 48, maxZoom: FOCUS_MAX_ZOOM, duration: 600 }); + }, [focus, focusPoints, isMapReady, recenterCount]); + // Flattened once per `visibleLayers` change and reused by both the signature and the effect // below, so serializing for the comparison costs no work the upload did not already do. const preparedLayers = useMemo(() => { @@ -472,6 +529,7 @@ const UnifiedMapViewComponent: React.FC = ({ return (
+ {focus && focusPoints ? : null} ); }; diff --git a/src/components/sidebar/__tests__/side-menu.test.tsx b/src/components/sidebar/__tests__/side-menu.test.tsx index 80fc3ac6..e5cd956c 100644 --- a/src/components/sidebar/__tests__/side-menu.test.tsx +++ b/src/components/sidebar/__tests__/side-menu.test.tsx @@ -140,6 +140,22 @@ describe('SideMenu', () => { }); }); + it('should navigate to Archived Calls from the Calls menu', async () => { + render(); + + fireEvent.press(screen.getByText('Calls')); + + await waitFor(() => { + expect(screen.getByText('menu.archived_calls')).toBeTruthy(); + }); + + fireEvent.press(screen.getByText('menu.archived_calls')); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/archived-calls'); + }); + }); + it('should work without onNavigate prop', async () => { render(); diff --git a/src/components/sidebar/side-menu.tsx b/src/components/sidebar/side-menu.tsx index 6efb7ed0..10ad2d1e 100644 --- a/src/components/sidebar/side-menu.tsx +++ b/src/components/sidebar/side-menu.tsx @@ -1,5 +1,6 @@ import { type Href, useRouter } from 'expo-router'; import { + Archive, Briefcase, CalendarClock, CloudLightning, @@ -64,6 +65,7 @@ const getMenuItems = (t: (key: string) => string, pendingCallsCount: number, ter ? [{ id: 'awaiting-resources', label: t('menu.awaiting_resources'), icon: ListChecks, route: '/awaiting-resources', badgeCount: awaitingResourcesCount, badgeColor: '#ea580c' }] : []), { id: 'scheduled-calls', label: t('menu.scheduled_calls'), icon: CalendarClock, route: '/scheduled-calls' }, + { id: 'archived-calls', label: t('menu.archived_calls'), icon: Archive, route: '/archived-calls' }, { id: 'new-call', label: t('menu.new_call'), icon: Plus, route: '/call/new' }, { id: 'incident-command', label: t('menu.incident_command'), icon: Network, route: '/incident-command' }, ], diff --git a/src/hooks/__tests__/use-coordinate-entry.test.ts b/src/hooks/__tests__/use-coordinate-entry.test.ts new file mode 100644 index 00000000..127e5800 --- /dev/null +++ b/src/hooks/__tests__/use-coordinate-entry.test.ts @@ -0,0 +1,250 @@ +import { act, renderHook } from '@testing-library/react-native'; + +import { reverseGeocode } from '@/api/geocoding/geocoding'; +import { COORDINATE_ENTRY_DELAY_MS, useCoordinateEntry, type UseCoordinateEntryOptions } from '@/hooks/use-coordinate-entry'; + +jest.mock('@/api/geocoding/geocoding', () => ({ reverseGeocode: jest.fn() })); +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); + +const mockedReverseGeocode = reverseGeocode as jest.Mock; + +const setup = (overrides: Partial = {}) => { + let props: UseCoordinateEntryOptions = { + latitudeText: '', + longitudeText: '', + setText: jest.fn(), + location: null, + address: '', + onLocationEntered: jest.fn(), + setAddress: jest.fn(), + onCleared: jest.fn(), + ...overrides, + }; + const hook = renderHook((p: UseCoordinateEntryOptions) => useCoordinateEntry(p), { initialProps: props }); + const update = (changes: Partial) => { + props = { ...props, ...changes }; + hook.rerender(props); + }; + + return { hook, update, props: () => props }; +}; + +// Lets the pause run out and the reverse geocode that follows it settle. +const pause = async () => { + await act(async () => { + jest.advanceTimersByTime(COORDINATE_ENTRY_DELAY_MS); + }); + await act(async () => { + for (let tick = 0; tick < 5; tick++) await Promise.resolve(); + }); +}; + +describe('useCoordinateEntry', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockedReverseGeocode.mockResolvedValue({ succeeded: true, address: '4924 Ponderosa Drive, Carson City, NV' }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('moves the pin once both values are in and the dispatcher pauses, and fills a blank address', async () => { + const { update, props } = setup(); + + update({ latitudeText: '39.122639', longitudeText: '-119.758773' }); + await act(async () => { + jest.advanceTimersByTime(COORDINATE_ENTRY_DELAY_MS - 1); + }); + expect(props().onLocationEntered).not.toHaveBeenCalled(); + + await pause(); + + expect(props().onLocationEntered).toHaveBeenCalledWith({ latitude: 39.122639, longitude: -119.758773 }); + expect(mockedReverseGeocode).toHaveBeenCalledWith(39.122639, -119.758773); + expect(props().setAddress).toHaveBeenCalledWith('4924 Ponderosa Drive, Carson City, NV'); + }); + + it('never replaces an address the dispatcher supplied', async () => { + const { update, props } = setup({ address: '4924 Ponderosa Dr (rear building)' }); + + update({ latitudeText: '39.122639', longitudeText: '-119.758773' }); + await pause(); + + expect(props().onLocationEntered).toHaveBeenCalledWith({ latitude: 39.122639, longitude: -119.758773 }); + expect(mockedReverseGeocode).not.toHaveBeenCalled(); + expect(props().setAddress).not.toHaveBeenCalled(); + }); + + it('waits for both values before moving the pin', async () => { + const { update, props } = setup(); + + update({ latitudeText: '39.122639' }); + await pause(); + + expect(props().onLocationEntered).not.toHaveBeenCalled(); + }); + + it('leaves the pin and address alone when the inputs show where the pin already is', async () => { + const { update, props } = setup({ location: { latitude: 39.1226391234, longitude: -119.7587731234 } }); + + update({ latitudeText: '39.122639', longitudeText: '-119.758773' }); + await pause(); + + expect(props().onLocationEntered).not.toHaveBeenCalled(); + expect(mockedReverseGeocode).not.toHaveBeenCalled(); + }); + + it('flags an invalid value after the pause, and clears the flag once it is fixed', async () => { + const { hook, update } = setup(); + + update({ latitudeText: '95', longitudeText: '-119.758773' }); + expect(hook.result.current.latitudeError).toBeUndefined(); + + await pause(); + expect(hook.result.current.latitudeError).toBe('calls.latitude_invalid'); + expect(hook.result.current.longitudeError).toBeUndefined(); + + update({ latitudeText: '39.122639' }); + expect(hook.result.current.latitudeError).toBeUndefined(); + }); + + it('splits a pasted "lat, lon" pair across both inputs', () => { + const { hook, props } = setup(); + + act(() => hook.result.current.changeText('latitude', '39.122639, -119.758773')); + + expect(props().setText).toHaveBeenCalledWith('latitude', '39.122639'); + expect(props().setText).toHaveBeenCalledWith('longitude', '-119.758773'); + }); + + it('applies the inputs at once on commit and flags a missing half', async () => { + const { hook, update, props } = setup(); + + update({ latitudeText: '39.122639' }); + let entry: ReturnType | undefined; + act(() => { + entry = hook.result.current.commit(); + }); + + expect(entry).toEqual({ status: 'incomplete', missing: 'longitude' }); + expect(hook.result.current.longitudeError).toBe('calls.coordinates_incomplete'); + + update({ longitudeText: '-119.758773' }); + await act(async () => { + entry = hook.result.current.commit(); + for (let tick = 0; tick < 5; tick++) await Promise.resolve(); + }); + + expect(entry).toEqual({ status: 'valid', latitude: 39.122639, longitude: -119.758773 }); + expect(props().onLocationEntered).toHaveBeenCalledTimes(1); + }); + + it('keeps the pin precision when committing inputs that echo it', () => { + const location = { latitude: 39.1226391234, longitude: -119.7587731234 }; + const { hook } = setup({ location, latitudeText: '39.122639', longitudeText: '-119.758773' }); + + let entry: ReturnType | undefined; + act(() => { + entry = hook.result.current.commit(); + }); + + expect(entry).toEqual({ status: 'valid', ...location }); + }); + + it('reports both inputs emptied', async () => { + const { update, props } = setup({ location: { latitude: 39.1, longitude: -119.7 }, latitudeText: '39.1', longitudeText: '-119.7' }); + + update({ latitudeText: '', longitudeText: '' }); + await pause(); + + expect(props().onCleared).toHaveBeenCalled(); + }); + + it('replaces an address it filled in itself when the point moves, but not one the dispatcher has since edited', async () => { + const { update, props } = setup(); + + update({ latitudeText: '39.122639', longitudeText: '-119.758773' }); + await pause(); + update({ address: '4924 Ponderosa Drive, Carson City, NV', location: { latitude: 39.122639, longitude: -119.758773 } }); + + mockedReverseGeocode.mockResolvedValue({ succeeded: true, address: '100 N Carson St, Carson City, NV' }); + update({ latitudeText: '39.1638' }); + await pause(); + expect(props().setAddress).toHaveBeenLastCalledWith('100 N Carson St, Carson City, NV'); + + update({ address: '100 N Carson St, Suite 2', location: { latitude: 39.1638, longitude: -119.758773 } }); + update({ latitudeText: '39.2' }); + await pause(); + expect(props().setAddress).toHaveBeenCalledTimes(2); + }); + + it('does not overwrite an address typed while the lookup was running', async () => { + let resolveLookup: (value: { succeeded: boolean; address: string }) => void = () => undefined; + mockedReverseGeocode.mockReturnValue(new Promise((resolve) => (resolveLookup = resolve))); + const { update, props } = setup(); + + update({ latitudeText: '39.122639', longitudeText: '-119.758773' }); + await act(async () => { + jest.advanceTimersByTime(COORDINATE_ENTRY_DELAY_MS); + }); + update({ address: 'Rear barn, 4924 Ponderosa' }); + await act(async () => { + resolveLookup({ succeeded: true, address: '4924 Ponderosa Drive' }); + for (let tick = 0; tick < 5; tick++) await Promise.resolve(); + }); + + expect(props().setAddress).not.toHaveBeenCalled(); + expect(props().onLocationEntered).toHaveBeenCalled(); + }); + + it('drops a lookup still running when both inputs are emptied', async () => { + let resolveLookup: (value: { succeeded: boolean; address: string }) => void = () => undefined; + mockedReverseGeocode.mockReturnValue(new Promise((resolve) => (resolveLookup = resolve))); + const { hook, update, props } = setup(); + + update({ latitudeText: '39.122639', longitudeText: '-119.758773' }); + await act(async () => { + jest.advanceTimersByTime(COORDINATE_ENTRY_DELAY_MS); + }); + expect(hook.result.current.isLookingUpAddress).toBe(true); + + update({ location: { latitude: 39.122639, longitude: -119.758773 }, latitudeText: '', longitudeText: '' }); + await act(async () => { + jest.advanceTimersByTime(COORDINATE_ENTRY_DELAY_MS); + }); + expect(props().onCleared).toHaveBeenCalled(); + expect(hook.result.current.isLookingUpAddress).toBe(false); + + await act(async () => { + resolveLookup({ succeeded: true, address: '4924 Ponderosa Drive' }); + for (let tick = 0; tick < 5; tick++) await Promise.resolve(); + }); + + expect(props().setAddress).not.toHaveBeenCalled(); + }); + + it('fills a blank address from a map pick with the address the picker found, without a lookup', async () => { + const { hook, props } = setup(); + + await act(async () => { + await hook.result.current.fillAddress({ latitude: 39.1, longitude: -119.7 }, 'Picked Street'); + }); + + expect(mockedReverseGeocode).not.toHaveBeenCalled(); + expect(props().setAddress).toHaveBeenCalledWith('Picked Street'); + }); + + it('leaves a dispatcher address alone on a map pick', async () => { + const { hook, props } = setup({ address: 'Known address' }); + + await act(async () => { + await hook.result.current.fillAddress({ latitude: 39.1, longitude: -119.7 }, 'Picked Street'); + await hook.result.current.fillAddress({ latitude: 39.1, longitude: -119.7 }); + }); + + expect(mockedReverseGeocode).not.toHaveBeenCalled(); + expect(props().setAddress).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/__tests__/use-selected-unit-map-focus.test.ts b/src/hooks/__tests__/use-selected-unit-map-focus.test.ts new file mode 100644 index 00000000..49bcd76a --- /dev/null +++ b/src/hooks/__tests__/use-selected-unit-map-focus.test.ts @@ -0,0 +1,73 @@ +import { act, renderHook } from '@testing-library/react-native'; + +import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; +import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store'; +import { useUnitsStore } from '@/stores/units/store'; + +import { useSelectedUnitMapFocus } from '../use-selected-unit-map-focus'; + +jest.mock('@/api/units/units', () => ({ getUnitsInfos: jest.fn() })); +jest.mock('@/api/satuses', () => ({ getAllUnitStatuses: jest.fn() })); + +const unit = (UnitId: string, Name: string, crew: string[]) => + ({ + UnitId, + Name, + Roles: [ + { RoleId: '1', RoleName: 'Officer', UserId: crew[0] ?? '', Name: '' }, + { RoleId: '2', RoleName: 'Driver', UserId: crew[1] ?? '', Name: '' }, + ], + }) as unknown as UnitInfoResultData; + +describe('useSelectedUnitMapFocus', () => { + beforeEach(() => { + act(() => { + useDispatchConsoleStore.getState().setSelectedUnitId(null); + useUnitsStore.setState({ units: [unit('12', 'Engine 12', ['ABC', 'def']), unit('13', 'Engine 13', [])] }); + }); + }); + + it('is null until a unit is selected', () => { + const { result } = renderHook(() => useSelectedUnitMapFocus()); + + expect(result.current).toBeNull(); + }); + + it('focuses the selected unit and its seated crew', () => { + const { result } = renderHook(() => useSelectedUnitMapFocus()); + + act(() => useDispatchConsoleStore.getState().setSelectedUnitId('12')); + + expect(result.current).toEqual({ key: 'unit:12', label: 'Engine 12', unitPinId: 'u12', crewPinIds: ['pabc', 'pdef'] }); + }); + + it('keeps the same focus through a units refetch that changes nothing, and the selection with it', () => { + const { result } = renderHook(() => useSelectedUnitMapFocus()); + act(() => useDispatchConsoleStore.getState().setSelectedUnitId('12')); + const first = result.current; + + act(() => useUnitsStore.setState({ units: [unit('12', 'Engine 12', ['ABC', 'def']), unit('13', 'Engine 13', [])] })); + + expect(result.current).toBe(first); + expect(useDispatchConsoleStore.getState().selectedUnitId).toBe('12'); + }); + + it('follows a crew change from a refetch', () => { + const { result } = renderHook(() => useSelectedUnitMapFocus()); + act(() => useDispatchConsoleStore.getState().setSelectedUnitId('12')); + + act(() => useUnitsStore.setState({ units: [unit('12', 'Engine 12', ['ghi']), unit('13', 'Engine 13', [])] })); + + expect(result.current?.crewPinIds).toEqual(['pghi']); + expect(useDispatchConsoleStore.getState().selectedUnitId).toBe('12'); + }); + + it('clears when the unit is deselected', () => { + const { result } = renderHook(() => useSelectedUnitMapFocus()); + act(() => useDispatchConsoleStore.getState().setSelectedUnitId('12')); + + act(() => useDispatchConsoleStore.getState().setSelectedUnitId(null)); + + expect(result.current).toBeNull(); + }); +}); diff --git a/src/hooks/use-coordinate-entry.ts b/src/hooks/use-coordinate-entry.ts new file mode 100644 index 00000000..7aa0ec8c --- /dev/null +++ b/src/hooks/use-coordinate-entry.ts @@ -0,0 +1,206 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { reverseGeocode } from '@/api/geocoding/geocoding'; +import { type CallPoint, type CoordinateAxis, type CoordinateEntry, isSameCoordinateSpot, parseCoordinate, parseCoordinateEntry, splitCoordinatePair } from '@/lib/call-coordinates'; +import { logger } from '@/lib/logging'; + +/** How long the latitude and longitude inputs sit still before what they hold moves the pin. */ +export const COORDINATE_ENTRY_DELAY_MS = 2000; + +type ShownError = 'invalid' | 'missing' | null; + +export interface UseCoordinateEntryOptions { + latitudeText: string | undefined; + longitudeText: string | undefined; + /** Writes one input's text. */ + setText: (axis: CoordinateAxis, text: string) => void; + /** The call location the form holds now: where the pin is. */ + location: CallPoint | null; + /** The address input as it stands. */ + address: string | undefined; + /** Typed coordinates settled on a new point: the call location (and the pin) moves there. */ + onLocationEntered: (location: CallPoint) => void; + /** Writes the address input with one found for the location, or clears one found for an earlier location. */ + setAddress: (address: string) => void; + /** Both inputs emptied. Left out where the location cannot be removed (an edit keeps the stored one). */ + onCleared?: () => void; +} + +/** + * The separate latitude and longitude inputs on the new and edit call screens. + * + * Once both hold valid decimal degrees and the dispatcher pauses, the pin moves there. A location typed in or + * picked on the map fills the address only when the dispatcher has not supplied one: geocoding is not always + * right, so the address they know to be correct is never replaced. An address this filled in itself is not + * theirs until they change it, so a later point replaces it. + */ +export const useCoordinateEntry = (options: UseCoordinateEntryOptions) => { + const { t } = useTranslation(); + const { latitudeText, longitudeText, setText } = options; + + // Read by the delayed check and the address lookup, which finish after the render that started them. + const optionsRef = useRef(options); + useEffect(() => { + optionsRef.current = options; + }); + + const [shownErrors, setShownErrors] = useState>({ latitude: null, longitude: null }); + const [isLookingUpAddress, setIsLookingUpAddress] = useState(false); + // The address last filled in from a location, as long as it has not been changed since. + const filledAddressRef = useRef(null); + // Only the latest lookup may write the address: an older one answering late would name the wrong place. + const lookupIdRef = useRef(0); + const timerRef = useRef | null>(null); + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + const isAddressFillable = () => { + const address = (optionsRef.current.address ?? '').trim(); + return !address || address === filledAddressRef.current; + }; + + const writeAddress = (address: string) => { + filledAddressRef.current = address.trim() || null; + optionsRef.current.setAddress(address); + }; + + /** Fills a blank address for a point the dispatcher chose, with one the map picker found already or by looking it up. */ + const fillAddress = useCallback(async (point: CallPoint, foundAddress?: string) => { + const lookupId = ++lookupIdRef.current; + + if (!isAddressFillable()) { + setIsLookingUpAddress(false); + return; + } + + if (foundAddress?.trim()) { + setIsLookingUpAddress(false); + writeAddress(foundAddress); + return; + } + + setIsLookingUpAddress(true); + let address: string | null = null; + try { + address = (await reverseGeocode(point.latitude, point.longitude)).address; + } catch (error) { + logger.error({ message: 'Failed to reverse geocode the call coordinates', context: { error, latitude: point.latitude, longitude: point.longitude } }); + } + + // A newer point took over, or the dispatcher typed an address while this ran. + if (lookupId !== lookupIdRef.current || !isMountedRef.current) return; + setIsLookingUpAddress(false); + if (!isAddressFillable()) return; + + if (address) { + writeAddress(address); + } else if (filledAddressRef.current) { + // Nothing found here: an address filled in for the previous point would now name the wrong place. + writeAddress(''); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + /** Acts on the inputs as they stand: errors shown, and a new point applied (or a cleared one removed). */ + const settle = useCallback( + (flagMissing: boolean): CoordinateEntry => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + + const { location, onLocationEntered, onCleared } = optionsRef.current; + const entry = parseCoordinateEntry(optionsRef.current.latitudeText, optionsRef.current.longitudeText); + const errorFor = (axis: CoordinateAxis): ShownError => { + if (entry.status === 'invalid') return (axis === 'latitude' ? entry.latitudeInvalid : entry.longitudeInvalid) ? 'invalid' : null; + return flagMissing && entry.status === 'incomplete' && entry.missing === axis ? 'missing' : null; + }; + setShownErrors({ latitude: errorFor('latitude'), longitude: errorFor('longitude') }); + + if (entry.status === 'valid') { + // The inputs show where the pin already is (a map click, a lookup, the stored call): keep its full + // precision, and leave the address alone. + if (location && isSameCoordinateSpot(entry, location)) { + return { status: 'valid', latitude: location.latitude, longitude: location.longitude }; + } + + const point = { latitude: entry.latitude, longitude: entry.longitude }; + onLocationEntered(point); + void fillAddress(point); + } else if (entry.status === 'empty' && location) { + // A lookup still running for the point being removed would put its address back. + lookupIdRef.current += 1; + setIsLookingUpAddress(false); + onCleared?.(); + } + + return entry; + }, + [fillAddress] + ); + + useEffect(() => { + timerRef.current = setTimeout(() => { + timerRef.current = null; + settle(false); + }, COORDINATE_ENTRY_DELAY_MS); + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [latitudeText, longitudeText, settle]); + + /** A whole "lat, lon" pair typed or pasted into either input is split across both. */ + const changeText = useCallback( + (axis: CoordinateAxis, text: string) => { + const pair = splitCoordinatePair(text); + + if (pair) { + setText('latitude', pair.latitude); + setText('longitude', pair.longitude); + return; + } + + setText(axis, text); + }, + [setText] + ); + + /** Applies the inputs now (Enter, or a save), rather than after the pause; missing halves are flagged too. */ + const commit = useCallback(() => settle(true), [settle]); + + // An error stays up only while the input still has it, so it clears as soon as the value is fixed. + const errorMessage = (axis: CoordinateAxis, text: string | undefined): string | undefined => { + const status = parseCoordinate(text, axis).status; + + if (shownErrors[axis] === 'invalid' && status === 'invalid') { + return t(axis === 'latitude' ? 'calls.latitude_invalid' : 'calls.longitude_invalid'); + } + + if (shownErrors[axis] === 'missing' && status === 'empty') { + return t('calls.coordinates_incomplete'); + } + + return undefined; + }; + + return { + latitudeError: errorMessage('latitude', latitudeText), + longitudeError: errorMessage('longitude', longitudeText), + isLookingUpAddress, + changeText, + commit, + fillAddress, + }; +}; diff --git a/src/hooks/use-selected-unit-map-focus.ts b/src/hooks/use-selected-unit-map-focus.ts new file mode 100644 index 00000000..bbb2353d --- /dev/null +++ b/src/hooks/use-selected-unit-map-focus.ts @@ -0,0 +1,23 @@ +import { useMemo } from 'react'; + +import { createUnitMapFocus, getCrewFocusPinIds, type MapFocus } from '@/lib/unit-map-focus'; +import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store'; +import { useUnitsStore } from '@/stores/units/store'; + +/** + * The console map's focus while the dispatcher has a unit selected: the unit and the members seated in its roles, read + * from the live units list so a crew change (unitActiveRolesUpdated, then a units refetch) re-frames the map. Null when + * no unit is selected. Realtime traffic only refreshes the unit; it never touches the selection. + * + * The result keeps its identity across refetches that change nothing on the map, so the memoized map does not re-render. + */ +export function useSelectedUnitMapFocus(): MapFocus | null { + const selectedUnitId = useDispatchConsoleStore((state) => state.selectedUnitId); + const unit = useUnitsStore((state) => (selectedUnitId ? state.units.find((candidate) => candidate.UnitId === selectedUnitId) : undefined)); + + const unitId = unit?.UnitId ?? null; + const label = unit?.Name ?? ''; + const crewKey = getCrewFocusPinIds(unit).join('|'); + + return useMemo(() => (unitId ? createUnitMapFocus(unitId, label, crewKey ? crewKey.split('|') : []) : null), [unitId, label, crewKey]); +} diff --git a/src/lib/__tests__/archived-calls.test.ts b/src/lib/__tests__/archived-calls.test.ts new file mode 100644 index 00000000..199bbb1d --- /dev/null +++ b/src/lib/__tests__/archived-calls.test.ts @@ -0,0 +1,180 @@ +import { type CallResultData } from '@/models/v4/calls/callResultData'; + +import { + type ArchivedCallForm, + archivedCallStateLabelKey, + archivedCallTypes, + buildArchivedCallRequest, + countActiveArchivedCallFilters, + EMPTY_ARCHIVED_CALL_FILTERS, + filterArchivedCalls, +} from '../archived-calls'; + +const call = (overrides: Partial): CallResultData => + ({ + CallId: '1', + Number: '26-1', + Name: 'Structure Fire', + Nature: 'Smoke showing', + Note: '', + Address: '1 Main St', + Type: 'Fire', + Priority: 1, + State: 1, + LoggedOn: '2026-03-05T14:22:00', + LoggedOnUtc: '2026-03-05T22:22:00', + ...overrides, + }) as CallResultData; + +const calls = [ + call({ CallId: '1', Number: '26-1', Name: 'Structure Fire', Type: 'Fire', Priority: 1, State: 1, LoggedOn: '2026-01-10T08:00:00' }), + call({ CallId: '2', Number: '26-2', Name: 'Welfare check', Nature: 'Adult in distress', Address: 'Exposition Park', Type: 'EMS', Priority: 2, State: 2, LoggedOn: '2026-02-14T12:00:00', IncidentId: 'INC-77' }), + call({ CallId: '3', Number: '26-3', Name: 'Alarm', Nature: 'Commercial alarm', Type: 'Fire', Priority: 3, State: 7, LoggedOn: '2026-03-01T23:59:00', ContactName: 'Jordan Lee' }), +]; + +const ids = (list: CallResultData[]) => list.map((c) => c.CallId); + +describe('filterArchivedCalls', () => { + it('keeps every call with no filters', () => { + expect(ids(filterArchivedCalls(calls, EMPTY_ARCHIVED_CALL_FILTERS))).toEqual(['1', '2', '3']); + }); + + it('matches every word of the search across the call, case-insensitively', () => { + expect(ids(filterArchivedCalls(calls, { ...EMPTY_ARCHIVED_CALL_FILTERS, query: 'exposition DISTRESS' }))).toEqual(['2']); + expect(ids(filterArchivedCalls(calls, { ...EMPTY_ARCHIVED_CALL_FILTERS, query: 'inc-77' }))).toEqual(['2']); + expect(ids(filterArchivedCalls(calls, { ...EMPTY_ARCHIVED_CALL_FILTERS, query: 'jordan' }))).toEqual(['3']); + expect(ids(filterArchivedCalls(calls, { ...EMPTY_ARCHIVED_CALL_FILTERS, query: '26-3' }))).toEqual(['3']); + expect(ids(filterArchivedCalls(calls, { ...EMPTY_ARCHIVED_CALL_FILTERS, query: 'fire distress' }))).toEqual([]); + }); + + it('filters by close type, priority and type', () => { + expect(ids(filterArchivedCalls(calls, { ...EMPTY_ARCHIVED_CALL_FILTERS, states: [2, 7] }))).toEqual(['2', '3']); + expect(ids(filterArchivedCalls(calls, { ...EMPTY_ARCHIVED_CALL_FILTERS, priorities: [1] }))).toEqual(['1']); + expect(ids(filterArchivedCalls(calls, { ...EMPTY_ARCHIVED_CALL_FILTERS, types: ['Fire'] }))).toEqual(['1', '3']); + expect(ids(filterArchivedCalls(calls, { ...EMPTY_ARCHIVED_CALL_FILTERS, types: ['Fire'], states: [1] }))).toEqual(['1']); + }); + + it('reads a state sent as a numeric string', () => { + expect(ids(filterArchivedCalls([call({ CallId: '9', State: '3' })], { ...EMPTY_ARCHIVED_CALL_FILTERS, states: [3] }))).toEqual(['9']); + }); + + it('filters by the department-local day the call was logged, both ends inclusive', () => { + expect(ids(filterArchivedCalls(calls, { ...EMPTY_ARCHIVED_CALL_FILTERS, fromDate: '2026-02-14' }))).toEqual(['2', '3']); + expect(ids(filterArchivedCalls(calls, { ...EMPTY_ARCHIVED_CALL_FILTERS, toDate: '2026-02-14' }))).toEqual(['1', '2']); + // 23:59 local on March 1st is still March 1st, whatever the UTC date. + expect(ids(filterArchivedCalls(calls, { ...EMPTY_ARCHIVED_CALL_FILTERS, fromDate: '2026-03-01', toDate: '2026-03-01' }))).toEqual(['3']); + }); + + it('counts the filters that narrow the list, not the search text', () => { + expect(countActiveArchivedCallFilters({ ...EMPTY_ARCHIVED_CALL_FILTERS, query: 'fire' })).toBe(0); + expect(countActiveArchivedCallFilters({ ...EMPTY_ARCHIVED_CALL_FILTERS, states: [1], types: ['Fire'], fromDate: '2026-01-01', toDate: '2026-02-01' })).toBe(3); + }); +}); + +describe('archivedCallTypes', () => { + it('lists the distinct types the calls carry, A-Z', () => { + expect(archivedCallTypes([...calls, call({ CallId: '4', Type: '' })])).toEqual(['EMS', 'Fire']); + }); +}); + +describe('archivedCallStateLabelKey', () => { + it('names the close types and nothing else', () => { + expect(archivedCallStateLabelKey(1)).toBe('call_detail.close_call_types.closed'); + expect(archivedCallStateLabelKey('7')).toBe('call_detail.close_call_types.false_alarm'); + expect(archivedCallStateLabelKey(0)).toBeNull(); + expect(archivedCallStateLabelKey(8)).toBeNull(); + }); +}); + +describe('buildArchivedCallRequest', () => { + const now = Date.parse('2026-10-23T16:00:00Z'); + const form = (overrides: Partial = {}): ArchivedCallForm => ({ + name: ' Structure Fire ', + nature: 'Smoke showing', + note: '', + priority: '2', + type: 'Fire', + address: '1 Main St', + latitude: '', + longitude: '', + contactName: '', + contactInfo: '', + incidentId: '', + referenceId: '', + externalId: '', + loggedOn: '2026-10-01T10:00:00.000Z', + closedOn: '', + state: '1', + closedNotes: 'Knocked down', + recalculateCallNumbers: false, + dispatch: { everyone: false, users: ['u1'], groups: [], roles: [], units: ['5'] }, + ...overrides, + }); + + const errorOf = (overrides: Partial) => { + const result = buildArchivedCallRequest(form(overrides), now); + return 'errorKey' in result ? result.errorKey : null; + }; + + it('builds the request from a complete form', () => { + const result = buildArchivedCallRequest(form({ closedOn: '2026-10-01T11:30:00.000Z', recalculateCallNumbers: true }), now); + + expect(result).toEqual({ + request: expect.objectContaining({ + name: 'Structure Fire', + nature: 'Smoke showing', + note: undefined, + address: '1 Main St', + latitude: undefined, + longitude: undefined, + priority: 2, + type: 'Fire', + dispatchEveryone: false, + dispatchUsers: ['u1'], + dispatchUnits: ['5'], + loggedOnUtc: '2026-10-01T10:00:00.000Z', + closedOnUtc: '2026-10-01T11:30:00.000Z', + state: 1, + closedNotes: 'Knocked down', + recalculateCallNumbers: true, + }), + }); + }); + + it('leaves the close time to the server when none was picked, and sends no type for "no type"', () => { + const result = buildArchivedCallRequest(form({ type: '' }), now); + + expect('request' in result && result.request.closedOnUtc).toBeUndefined(); + expect('request' in result && result.request.type).toBeUndefined(); + }); + + it('requires a name, a nature, a priority and a close type', () => { + expect(errorOf({ name: ' ' })).toBe('archived_calls.name_required'); + expect(errorOf({ nature: '' })).toBe('archived_calls.nature_required'); + expect(errorOf({ priority: '' })).toBe('archived_calls.priority_required'); + expect(errorOf({ state: '0' })).toBe('archived_calls.state_required'); + expect(errorOf({ state: '8' })).toBe('archived_calls.state_required'); + }); + + it('refuses a call logged in the future, or closed before it was logged or in the future', () => { + expect(errorOf({ loggedOn: '' })).toBe('archived_calls.logged_on_required'); + expect(errorOf({ loggedOn: '2026-10-24T10:00:00.000Z' })).toBe('archived_calls.logged_on_future'); + expect(errorOf({ closedOn: '2026-10-01T09:59:00.000Z' })).toBe('archived_calls.closed_on_before_logged'); + expect(errorOf({ closedOn: '2026-10-24T10:00:00.000Z' })).toBe('archived_calls.closed_on_future'); + // A little clock drift is not the future. + expect(errorOf({ loggedOn: '2026-10-23T16:02:00.000Z' })).toBeNull(); + }); + + it('takes both coordinates or neither', () => { + expect(errorOf({ latitude: '39.12' })).toBe('archived_calls.coordinates_invalid'); + expect(errorOf({ latitude: '91', longitude: '10' })).toBe('archived_calls.coordinates_invalid'); + expect(errorOf({ latitude: 'abc', longitude: '10' })).toBe('archived_calls.coordinates_invalid'); + // Decimal degrees only: no exponents, hex or decimal commas. + expect(errorOf({ latitude: '1e1', longitude: '10' })).toBe('archived_calls.coordinates_invalid'); + expect(errorOf({ latitude: '39.12', longitude: '0x10' })).toBe('archived_calls.coordinates_invalid'); + expect(errorOf({ latitude: '39,12', longitude: '10' })).toBe('archived_calls.coordinates_invalid'); + + const result = buildArchivedCallRequest(form({ latitude: ' 39.122639 ', longitude: '-119.758773' }), now); + expect('request' in result && [result.request.latitude, result.request.longitude]).toEqual([39.122639, -119.758773]); + }); +}); diff --git a/src/lib/__tests__/call-coordinates.test.ts b/src/lib/__tests__/call-coordinates.test.ts new file mode 100644 index 00000000..bc85d2fd --- /dev/null +++ b/src/lib/__tests__/call-coordinates.test.ts @@ -0,0 +1,85 @@ +import { formatCoordinate, formatStoredCoordinate, isSameCoordinateSpot, parseCoordinate, parseCoordinateEntry, splitCoordinatePair } from '../call-coordinates'; + +describe('parseCoordinate', () => { + it('reads signed decimal degrees', () => { + expect(parseCoordinate('39.122639', 'latitude')).toEqual({ status: 'valid', value: 39.122639 }); + expect(parseCoordinate('-119.758773', 'longitude')).toEqual({ status: 'valid', value: -119.758773 }); + expect(parseCoordinate('+45', 'latitude')).toEqual({ status: 'valid', value: 45 }); + expect(parseCoordinate(' 12.5 ', 'longitude')).toEqual({ status: 'valid', value: 12.5 }); + }); + + it('treats a blank input as empty, not invalid', () => { + expect(parseCoordinate('', 'latitude')).toEqual({ status: 'empty' }); + expect(parseCoordinate(' ', 'longitude')).toEqual({ status: 'empty' }); + expect(parseCoordinate(undefined, 'latitude')).toEqual({ status: 'empty' }); + }); + + it('keeps each axis on the globe', () => { + expect(parseCoordinate('90', 'latitude').status).toBe('valid'); + expect(parseCoordinate('-90', 'latitude').status).toBe('valid'); + expect(parseCoordinate('90.000001', 'latitude').status).toBe('invalid'); + expect(parseCoordinate('-119.75', 'latitude').status).toBe('invalid'); + expect(parseCoordinate('180', 'longitude').status).toBe('valid'); + expect(parseCoordinate('-180.5', 'longitude').status).toBe('invalid'); + }); + + it('refuses anything that is not plain decimal notation', () => { + for (const text of ['39.', '.5', '39,12', '1e2', '39°07\'21"', '39.1N', 'abc', '39.1.2', '--39', '1234.5', '39 12']) { + expect(parseCoordinate(text, 'longitude').status).toBe('invalid'); + } + }); +}); + +describe('parseCoordinateEntry', () => { + it('is a point once both inputs are valid', () => { + expect(parseCoordinateEntry('39.122639', '-119.758773')).toEqual({ status: 'valid', latitude: 39.122639, longitude: -119.758773 }); + }); + + it('is empty when both inputs are blank', () => { + expect(parseCoordinateEntry('', ' ')).toEqual({ status: 'empty' }); + }); + + it('names the half that is missing', () => { + expect(parseCoordinateEntry('39.1', '')).toEqual({ status: 'incomplete', missing: 'longitude' }); + expect(parseCoordinateEntry('', '-119.7')).toEqual({ status: 'incomplete', missing: 'latitude' }); + }); + + it('says which input is invalid, even when the other is blank', () => { + expect(parseCoordinateEntry('95', '-119.7')).toEqual({ status: 'invalid', latitudeInvalid: true, longitudeInvalid: false }); + expect(parseCoordinateEntry('', 'west')).toEqual({ status: 'invalid', latitudeInvalid: false, longitudeInvalid: true }); + }); +}); + +describe('splitCoordinatePair', () => { + it('splits a pasted "lat, lon" pair as typed', () => { + expect(splitCoordinatePair('39.122639, -119.758773')).toEqual({ latitude: '39.122639', longitude: '-119.758773' }); + expect(splitCoordinatePair('39.122639,-119.758773')).toEqual({ latitude: '39.122639', longitude: '-119.758773' }); + expect(splitCoordinatePair(' 39.122639 -119.758773 ')).toEqual({ latitude: '39.122639', longitude: '-119.758773' }); + }); + + it('leaves a single value, or a pair off the globe, to the input it was typed in', () => { + expect(splitCoordinatePair('39.122639')).toBeNull(); + expect(splitCoordinatePair('-119.758773, 39.122639')).toBeNull(); + expect(splitCoordinatePair('39.1, ')).toBeNull(); + }); +}); + +describe('formatting and comparing', () => { + it('shows a point to six places', () => { + expect(formatCoordinate(39.12263912345)).toBe('39.122639'); + expect(formatCoordinate(-119.7)).toBe('-119.700000'); + }); + + it('reads a stored coordinate, or nothing when it is not a number', () => { + expect(formatStoredCoordinate('39.1226391')).toBe('39.122639'); + expect(formatStoredCoordinate('')).toBe(''); + expect(formatStoredCoordinate(null)).toBe(''); + expect(formatStoredCoordinate('n/a')).toBe(''); + }); + + it('matches points that show the same in the inputs', () => { + expect(isSameCoordinateSpot({ latitude: 39.1226391, longitude: -119.7587734 }, { latitude: 39.122639, longitude: -119.758773 })).toBe(true); + expect(isSameCoordinateSpot({ latitude: 39.12264, longitude: -119.758773 }, { latitude: 39.122639, longitude: -119.758773 })).toBe(false); + expect(isSameCoordinateSpot(null, { latitude: 1, longitude: 2 })).toBe(false); + }); +}); diff --git a/src/lib/__tests__/call-field-policy.test.ts b/src/lib/__tests__/call-field-policy.test.ts index 4ed2b045..f9d06b62 100644 --- a/src/lib/__tests__/call-field-policy.test.ts +++ b/src/lib/__tests__/call-field-policy.test.ts @@ -7,6 +7,7 @@ import { getMissingCallFieldsFromError, hasDispatchRecipients, keepHiddenEditFieldsUnchanged, + sectionsHolding, toNewCallFieldKey, toProtocolIds, } from '@/lib/call-field-policy'; @@ -50,6 +51,23 @@ describe('call field labels', () => { }); }); +describe('sectionsHolding', () => { + const sections = { + contact: [NewCallFieldKeys.ContactName, NewCallFieldKeys.ContactInfo], + protocols: [NewCallFieldKeys.Protocols], + location: [NewCallFieldKeys.Address], + }; + + it('names the cards holding a matching field', () => { + const required = new Set([NewCallFieldKeys.ContactInfo, NewCallFieldKeys.Address]); + expect(sectionsHolding(sections, (key) => required.has(key))).toEqual(['contact', 'location']); + }); + + it('names none when nothing matches', () => { + expect(sectionsHolding(sections, () => false)).toEqual([]); + }); +}); + describe('hasDispatchRecipients', () => { it('is true for everyone or any picked recipient', () => { expect(hasDispatchRecipients({ everyone: true, users: [], groups: [], roles: [], units: [] })).toBe(true); diff --git a/src/lib/__tests__/unit-map-focus.test.ts b/src/lib/__tests__/unit-map-focus.test.ts new file mode 100644 index 00000000..818a3a02 --- /dev/null +++ b/src/lib/__tests__/unit-map-focus.test.ts @@ -0,0 +1,164 @@ +import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; +import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; + +import { + boundsForFocusPoints, + createUnitMapFocus, + FOCUS_MIN_SPAN_DEGREES, + focusFrameKey, + getCrewFocusPinIds, + hasPointLeftView, + resolveMapFocusPoints, + visibleBoundsFromCorners, +} from '../unit-map-focus'; + +const pin = (Id: string, Type: number, Latitude: number, Longitude: number) => ({ Id, Type, Latitude, Longitude, Title: Id }) as MapMakerInfoData; + +const unitWithRoles = (roles: { UserId: string; RoleName: string }[]) => ({ Roles: roles.map((role, index) => ({ RoleId: String(index + 1), Name: '', ...role })) }) as unknown as UnitInfoResultData; + +describe('getCrewFocusPinIds', () => { + it('lists the seated members in role order, once each, and skips empty seats', () => { + const unit = unitWithRoles([ + { UserId: 'Officer-1', RoleName: 'Officer' }, + { UserId: '', RoleName: 'Driver' }, + { UserId: 'ff-2', RoleName: 'Firefighter' }, + { UserId: 'officer-1', RoleName: 'EMT' }, + ]); + + expect(getCrewFocusPinIds(unit)).toEqual(['pofficer-1', 'pff-2']); + }); + + it('has no crew for a unit without roles', () => { + expect(getCrewFocusPinIds(undefined)).toEqual([]); + expect(getCrewFocusPinIds({ Roles: [] } as unknown as UnitInfoResultData)).toEqual([]); + }); +}); + +describe('resolveMapFocusPoints', () => { + const focus = createUnitMapFocus('12', 'Engine 12', ['pabc', 'pdef', 'pghi']); + + it('finds the unit and the located crew, matching ids case-insensitively', () => { + const located = resolveMapFocusPoints([pin('c12', 0, 1, 1), pin('U12', 1, 39.2, -119.2), pin('pABC', 3, 39.3, -119.3), pin('pDEF', 3, 39.4, -119.4), pin('pzzz', 3, 40, -120)], focus); + + expect(located.points).toEqual([ + { pinId: 'u12', coordinate: [-119.2, 39.2] }, + { pinId: 'pabc', coordinate: [-119.3, 39.3] }, + { pinId: 'pdef', coordinate: [-119.4, 39.4] }, + ]); + expect(located.unitLocated).toBe(true); + expect(located.crewLocated).toBe(2); + expect(located.crewTotal).toBe(3); + }); + + it('reads the bare ids an older server sends', () => { + const located = resolveMapFocusPoints([pin('12', 1, 39.2, -119.2), pin('ABC', 3, 39.3, -119.3)], focus); + + expect(located.points.map((point) => point.pinId)).toEqual(['u12', 'pabc']); + }); + + it('frames the crew alone when the unit has no position', () => { + const located = resolveMapFocusPoints([pin('pabc', 3, 39.3, -119.3)], focus); + + expect(located.unitLocated).toBe(false); + expect(located.points).toEqual([{ pinId: 'pabc', coordinate: [-119.3, 39.3] }]); + }); + + it('ignores positions fitBounds would choke on', () => { + const located = resolveMapFocusPoints([pin('u12', 1, 0, 0), pin('pabc', 3, 95, -119), pin('pdef', 3, Number.NaN, -119)], focus); + + expect(located.points).toEqual([]); + }); +}); + +describe('boundsForFocusPoints', () => { + it('is null without points', () => { + expect(boundsForFocusPoints([])).toBeNull(); + }); + + it('widens a single point to the minimum span around it', () => { + const bounds = boundsForFocusPoints([[-119.3, 39.3]])!; + + expect(bounds.ne[0] - bounds.sw[0]).toBeCloseTo(FOCUS_MIN_SPAN_DEGREES, 9); + expect(bounds.ne[1] - bounds.sw[1]).toBeCloseTo(FOCUS_MIN_SPAN_DEGREES, 9); + expect((bounds.ne[0] + bounds.sw[0]) / 2).toBeCloseTo(-119.3, 9); + expect((bounds.ne[1] + bounds.sw[1]) / 2).toBeCloseTo(39.3, 9); + }); + + it('keeps a spread-out crew as it is', () => { + expect( + boundsForFocusPoints([ + [-119.3, 39.3], + [-119.1, 39.5], + [-119.2, 39.4], + ]) + ).toEqual({ sw: [-119.3, 39.3], ne: [-119.1, 39.5] }); + }); + + it('never widens past the poles', () => { + const bounds = boundsForFocusPoints([[0.5, 89.9999]])!; + + expect(bounds.ne[1]).toBe(90); + }); +}); + +describe('focusFrameKey', () => { + const focus = createUnitMapFocus('12', 'Engine 12', ['pabc', 'pdef']); + + it('stays the same while positions move and changes when someone starts reporting', () => { + const before = focusFrameKey(focus, resolveMapFocusPoints([pin('u12', 1, 39.2, -119.2), pin('pabc', 3, 39.3, -119.3)], focus)); + const moved = focusFrameKey(focus, resolveMapFocusPoints([pin('u12', 1, 39.25, -119.25), pin('pabc', 3, 39.35, -119.35)], focus)); + const joined = focusFrameKey(focus, resolveMapFocusPoints([pin('u12', 1, 39.2, -119.2), pin('pabc', 3, 39.3, -119.3), pin('pdef', 3, 39.4, -119.4)], focus)); + + expect(moved).toBe(before); + expect(joined).not.toBe(before); + }); + + it('changes with the unit', () => { + const other = createUnitMapFocus('13', 'Engine 13', ['pabc']); + + expect(focusFrameKey(other, resolveMapFocusPoints([], other))).not.toBe(focusFrameKey(focus, resolveMapFocusPoints([], focus))); + }); +}); + +describe('hasPointLeftView', () => { + const visible = visibleBoundsFromCorners([ + [-119, 40], + [-120, 39], + ])!; + + it('follows a member who was on screen and has moved off it', () => { + const previous = new Map([['pabc', [-119.5, 39.5]]]); + + expect(hasPointLeftView(previous, [{ pinId: 'pabc', coordinate: [-118.5, 39.5] }], visible)).toBe(true); + }); + + it('ignores moves that stay on screen, members who were already off it, and members seen for the first time', () => { + const previous = new Map([ + ['pabc', [-119.5, 39.5]], + ['pdef', [-110, 30]], + ]); + + expect( + hasPointLeftView( + previous, + [ + { pinId: 'pabc', coordinate: [-119.4, 39.6] }, + { pinId: 'pdef', coordinate: [-111, 31] }, + { pinId: 'pghi', coordinate: [-100, 20] }, + ], + visible + ) + ).toBe(false); + }); + + it('reads native corners that cross the antimeridian', () => { + const across = visibleBoundsFromCorners([ + [-179, 10], + [179, -10], + ])!; + + expect(across.contains([179.5, 0])).toBe(true); + expect(across.contains([-179.5, 0])).toBe(true); + expect(across.contains([0, 0])).toBe(false); + }); +}); diff --git a/src/lib/archived-calls.ts b/src/lib/archived-calls.ts new file mode 100644 index 00000000..47269b94 --- /dev/null +++ b/src/lib/archived-calls.ts @@ -0,0 +1,171 @@ +import { type CreateArchivedCallRequest } from '@/api/calls/calls'; +import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { type DispatchSelection } from '@/stores/dispatch/store'; + +import { parseCoordinateEntry } from './call-coordinates'; +import { CallState } from './utils'; + +/** The close types an archived call can have, Closed (1) through False Alarm (7), with the close sheet's labels. */ +export const ARCHIVED_CALL_STATES: readonly { value: number; labelKey: string }[] = [ + { value: CallState.CLOSED, labelKey: 'call_detail.close_call_types.closed' }, + { value: CallState.CANCELLED, labelKey: 'call_detail.close_call_types.cancelled' }, + { value: CallState.UNFOUNDED, labelKey: 'call_detail.close_call_types.unfounded' }, + { value: CallState.FOUNDED, labelKey: 'call_detail.close_call_types.founded' }, + { value: CallState.MINOR, labelKey: 'call_detail.close_call_types.minor' }, + { value: CallState.TRANSFERRED, labelKey: 'call_detail.close_call_types.transferred' }, + { value: CallState.FALSE_ALARM, labelKey: 'call_detail.close_call_types.false_alarm' }, +]; + +/** The translation key for an archived call's close type, or null for a state that is not one. */ +export const archivedCallStateLabelKey = (state: number | string | null | undefined): string | null => ARCHIVED_CALL_STATES.find((s) => s.value === Number(state))?.labelKey ?? null; + +export interface ArchivedCallFilters { + /** Free text; every word must appear somewhere in the call (number, name, nature, notes, address, ids, contact). */ + query: string; + /** Close types to keep; empty keeps all. */ + states: number[]; + /** Priority ids to keep; empty keeps all. */ + priorities: number[]; + /** Call type names to keep; empty keeps all. */ + types: string[]; + /** First day (YYYY-MM-DD, the department's calendar) a call may have been logged on; empty for no limit. */ + fromDate: string; + /** Last day (YYYY-MM-DD) a call may have been logged on; empty for no limit. */ + toDate: string; +} + +export const EMPTY_ARCHIVED_CALL_FILTERS: ArchivedCallFilters = { query: '', states: [], priorities: [], types: [], fromDate: '', toDate: '' }; + +/** How many of the filters narrow the list, the search text not counted. */ +export const countActiveArchivedCallFilters = (filters: ArchivedCallFilters): number => + (filters.states.length > 0 ? 1 : 0) + (filters.priorities.length > 0 ? 1 : 0) + (filters.types.length > 0 ? 1 : 0) + (filters.fromDate || filters.toDate ? 1 : 0); + +const searchableText = (call: CallResultData): string => + [call.Number, call.CallId, call.Name, call.Nature, call.Note, call.Address, call.Type, call.DestinationName, call.IncidentId, call.ReferenceId, call.ExternalId, call.ContactName, call.ContactInfo] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .join('\n') + .toLowerCase(); + +/** + * The calendar day a call was logged, YYYY-MM-DD in the department's time (LoggedOn is department-local), so a + * date filter means the same day the dispatcher sees in the list. + */ +const loggedOnDay = (call: CallResultData): string => (call.LoggedOn || call.LoggedOnUtc || '').slice(0, 10); + +/** The calls that pass every filter, in the order given. */ +export const filterArchivedCalls = (calls: readonly CallResultData[], filters: ArchivedCallFilters): CallResultData[] => { + const terms = filters.query.toLowerCase().split(/\s+/).filter(Boolean); + const states = new Set(filters.states); + const priorities = new Set(filters.priorities); + const types = new Set(filters.types); + + return calls.filter((call) => { + if (states.size > 0 && !states.has(Number(call.State))) return false; + if (priorities.size > 0 && !priorities.has(call.Priority)) return false; + if (types.size > 0 && !types.has(call.Type || '')) return false; + + if (filters.fromDate || filters.toDate) { + const day = loggedOnDay(call); + if (!day) return false; + if (filters.fromDate && day < filters.fromDate) return false; + if (filters.toDate && day > filters.toDate) return false; + } + + if (terms.length > 0) { + const text = searchableText(call); + return terms.every((term) => text.includes(term)); + } + + return true; + }); +}; + +/** The call types the loaded calls carry, A-Z: archived calls can have types the department has since removed. */ +export const archivedCallTypes = (calls: readonly CallResultData[]): string[] => + Array.from(new Set(calls.map((call) => call.Type).filter((type): type is string => typeof type === 'string' && type.length > 0))).sort((a, b) => a.localeCompare(b)); + +/** The Add Archived Call form, as entered. Dates are ISO instants from the date-time picker; '' when not picked. */ +export interface ArchivedCallForm { + name: string; + nature: string; + note: string; + priority: string; + type: string; + address: string; + latitude: string; + longitude: string; + contactName: string; + contactInfo: string; + incidentId: string; + referenceId: string; + externalId: string; + loggedOn: string; + closedOn: string; + state: string; + closedNotes: string; + recalculateCallNumbers: boolean; + dispatch: DispatchSelection; +} + +/** How far ahead of this device's clock a time may be and still count as now (clocks drift). */ +const CLOCK_SKEW_MS = 5 * 60 * 1000; + +/** + * The save request for the form, or the translation key of the first thing wrong with it. Mirrors the server's + * checks so a dispatcher hears about a time in the future or a close before the call before anything is sent. + */ +export const buildArchivedCallRequest = (form: ArchivedCallForm, now: number = Date.now()): { request: CreateArchivedCallRequest } | { errorKey: string } => { + if (!form.name.trim()) return { errorKey: 'archived_calls.name_required' }; + if (!form.nature.trim()) return { errorKey: 'archived_calls.nature_required' }; + + const priority = Number(form.priority); + if (form.priority === '' || !Number.isInteger(priority)) return { errorKey: 'archived_calls.priority_required' }; + + const state = Number(form.state); + if (!ARCHIVED_CALL_STATES.some((s) => s.value === state)) return { errorKey: 'archived_calls.state_required' }; + + const loggedOn = Date.parse(form.loggedOn); + if (!form.loggedOn || Number.isNaN(loggedOn)) return { errorKey: 'archived_calls.logged_on_required' }; + if (loggedOn > now + CLOCK_SKEW_MS) return { errorKey: 'archived_calls.logged_on_future' }; + + const closedOn = form.closedOn ? Date.parse(form.closedOn) : null; + if (closedOn !== null) { + if (Number.isNaN(closedOn)) return { errorKey: 'archived_calls.closed_on_invalid' }; + if (closedOn < loggedOn) return { errorKey: 'archived_calls.closed_on_before_logged' }; + if (closedOn > now + CLOCK_SKEW_MS) return { errorKey: 'archived_calls.closed_on_future' }; + } + + // Both or neither, in decimal degrees as the new and edit call screens take them: one coordinate alone is not a place. + const coordinates = parseCoordinateEntry(form.latitude, form.longitude); + if (coordinates.status === 'invalid' || coordinates.status === 'incomplete') return { errorKey: 'archived_calls.coordinates_invalid' }; + const latitude = coordinates.status === 'valid' ? coordinates.latitude : undefined; + const longitude = coordinates.status === 'valid' ? coordinates.longitude : undefined; + + return { + request: { + name: form.name.trim(), + nature: form.nature.trim(), + note: form.note.trim() || undefined, + address: form.address.trim() || undefined, + latitude, + longitude, + priority, + type: form.type || undefined, + contactName: form.contactName.trim() || undefined, + contactInfo: form.contactInfo.trim() || undefined, + incidentId: form.incidentId.trim() || undefined, + referenceId: form.referenceId.trim() || undefined, + externalId: form.externalId.trim() || undefined, + dispatchEveryone: form.dispatch.everyone, + dispatchUsers: form.dispatch.users, + dispatchGroups: form.dispatch.groups, + dispatchRoles: form.dispatch.roles, + dispatchUnits: form.dispatch.units, + loggedOnUtc: new Date(loggedOn).toISOString(), + closedOnUtc: closedOn !== null ? new Date(closedOn).toISOString() : undefined, + state, + closedNotes: form.closedNotes.trim() || undefined, + recalculateCallNumbers: form.recalculateCallNumbers, + }, + }; +}; diff --git a/src/lib/call-coordinates.ts b/src/lib/call-coordinates.ts new file mode 100644 index 00000000..cc1e4ff8 --- /dev/null +++ b/src/lib/call-coordinates.ts @@ -0,0 +1,94 @@ +/** + * Typed call coordinates: the separate latitude and longitude inputs on the new and edit call screens. + * + * Only signed decimal degrees are taken ("39.122639", "-119.758773"): no degrees-minutes-seconds, hemisphere + * letters, exponents or decimal commas, which a dispatcher could mean several ways and the server would not read. + */ +export type CoordinateAxis = 'latitude' | 'longitude'; + +export interface CallPoint { + latitude: number; + longitude: number; +} + +const AXIS_LIMITS: Record = { latitude: 90, longitude: 180 }; + +// An optional sign, one to three whole digits and an optional fraction. "39." and ".5" are half-typed, not values. +const DECIMAL_DEGREES = '[-+]?\\d{1,3}(?:\\.\\d+)?'; +const DECIMAL_DEGREES_PATTERN = new RegExp(`^${DECIMAL_DEGREES}$`); +// "lat, lon" or "lat lon", as map apps copy a point. +const COORDINATE_PAIR_PATTERN = new RegExp(`^(${DECIMAL_DEGREES})\\s*(?:,\\s*|\\s+)(${DECIMAL_DEGREES})$`); + +export type CoordinateParse = { status: 'empty' } | { status: 'invalid' } | { status: 'valid'; value: number }; + +/** One input's text: blank, a decimal-degree value on the globe, or anything else. */ +export const parseCoordinate = (text: string | undefined, axis: CoordinateAxis): CoordinateParse => { + const trimmed = (text ?? '').trim(); + + if (!trimmed) { + return { status: 'empty' }; + } + + if (!DECIMAL_DEGREES_PATTERN.test(trimmed)) { + return { status: 'invalid' }; + } + + const value = Number(trimmed); + + if (!Number.isFinite(value) || Math.abs(value) > AXIS_LIMITS[axis]) { + return { status: 'invalid' }; + } + + return { status: 'valid', value }; +}; + +export type CoordinateEntry = + | { status: 'empty' } + /** One input filled in and the other blank. */ + | { status: 'incomplete'; missing: CoordinateAxis } + | { status: 'invalid'; latitudeInvalid: boolean; longitudeInvalid: boolean } + | ({ status: 'valid' } & CallPoint); + +/** Both inputs together: a usable point only when each holds a valid value. */ +export const parseCoordinateEntry = (latitudeText: string | undefined, longitudeText: string | undefined): CoordinateEntry => { + const latitude = parseCoordinate(latitudeText, 'latitude'); + const longitude = parseCoordinate(longitudeText, 'longitude'); + + if (latitude.status === 'invalid' || longitude.status === 'invalid') { + return { status: 'invalid', latitudeInvalid: latitude.status === 'invalid', longitudeInvalid: longitude.status === 'invalid' }; + } + + if (latitude.status === 'valid' && longitude.status === 'valid') { + return { status: 'valid', latitude: latitude.value, longitude: longitude.value }; + } + + if (latitude.status === 'empty' && longitude.status === 'empty') { + return { status: 'empty' }; + } + + return { status: 'incomplete', missing: latitude.status === 'empty' ? 'latitude' : 'longitude' }; +}; + +/** A whole "lat, lon" pair pasted or typed into one input, split into the text for each, as typed. */ +export const splitCoordinatePair = (text: string | undefined): { latitude: string; longitude: string } | null => { + const match = (text ?? '').trim().match(COORDINATE_PAIR_PATTERN); + + if (!match || parseCoordinateEntry(match[1], match[2]).status !== 'valid') { + return null; + } + + return { latitude: match[1], longitude: match[2] }; +}; + +/** How the inputs show a point the map or a lookup chose: six places, about 10 cm. */ +export const formatCoordinate = (value: number): string => value.toFixed(6); + +/** A coordinate as a call stores it (text), for the inputs: blank when it is not a number. */ +export const formatStoredCoordinate = (stored: string | null | undefined): string => { + const value = stored ? parseFloat(stored) : NaN; + return Number.isFinite(value) ? formatCoordinate(value) : ''; +}; + +/** Whether two points are the same spot as the inputs show it, so a point the inputs echo back is not re-applied. */ +export const isSameCoordinateSpot = (a: CallPoint | null | undefined, b: CallPoint | null | undefined): boolean => + !!a && !!b && formatCoordinate(a.latitude) === formatCoordinate(b.latitude) && formatCoordinate(a.longitude) === formatCoordinate(b.longitude); diff --git a/src/lib/call-field-policy.ts b/src/lib/call-field-policy.ts index d9681363..bdff9af3 100644 --- a/src/lib/call-field-policy.ts +++ b/src/lib/call-field-policy.ts @@ -53,6 +53,14 @@ export const describeCallFields = (keys: readonly string[], t: (key: string) => }) .join(', '); +/** + * The collapsible cards on a call form that hold one of the given fields: the ones the department requires, so + * those cards open by default instead of tucking away something the call cannot be saved without, or the ones a + * save was refused for, so the dispatcher lands on them. + */ +export const sectionsHolding = (sectionFields: Record, holds: (key: NewCallFieldKey) => boolean): S[] => + (Object.keys(sectionFields) as S[]).filter((section) => sectionFields[section].some(holds)); + /** True when a dispatch selection names anyone: everyone, or at least one person, group, role or unit. */ export const hasDispatchRecipients = (selection: DispatchListSelection | null | undefined): boolean => !!selection && (!!selection.everyone || (selection.users?.length ?? 0) > 0 || (selection.groups?.length ?? 0) > 0 || (selection.roles?.length ?? 0) > 0 || (selection.units?.length ?? 0) > 0); diff --git a/src/lib/udf/__tests__/options.test.ts b/src/lib/udf/__tests__/options.test.ts index eb5d7d66..7719ad0b 100644 --- a/src/lib/udf/__tests__/options.test.ts +++ b/src/lib/udf/__tests__/options.test.ts @@ -1,4 +1,4 @@ -import { comboDisplayText, filterComboSuggestions, findComboOption, parseUdfOptions, selectedOptionKeys, UDF_FIELD_TYPE, type UdfOption } from '../options'; +import { comboDisplayText, filterComboSuggestions, findComboOption, isUdfFieldShown, missingRequiredUdfFields, parseUdfOptions, selectedOptionKeys, UDF_FIELD_TYPE, type UdfOption } from '../options'; // What the server stores in UdfField.ValidationRules (Newtonsoft, PascalCase). const serverRules = JSON.stringify({ @@ -95,3 +95,40 @@ describe('selectedOptionKeys', () => { expect(selectedOptionKeys(UDF_FIELD_TYPE.Dropdown, '')).toEqual([]); }); }); + +describe('isUdfFieldShown', () => { + it('shows enabled app fields, and required ones wherever they are hidden', () => { + expect(isUdfFieldShown({ IsEnabled: true, IsVisibleOnMobile: true, IsRequired: false })).toBe(true); + expect(isUdfFieldShown({ IsEnabled: true, IsVisibleOnMobile: false, IsRequired: true })).toBe(true); + expect(isUdfFieldShown({ IsEnabled: true, IsVisibleOnMobile: false, IsRequired: false })).toBe(false); + expect(isUdfFieldShown({ IsEnabled: false, IsVisibleOnMobile: true, IsRequired: true })).toBe(false); + }); +}); + +describe('missingRequiredUdfFields', () => { + const fields = [ + { UdfFieldId: 'referral', IsRequired: true }, + { UdfFieldId: 'language', IsRequired: false }, + { UdfFieldId: 'coresponse', IsRequired: true }, + { UdfFieldId: 'mrn', IsRequired: true }, + ]; + + it('names the required fields left blank, whitespace counting as blank', () => { + const missing = missingRequiredUdfFields(fields, [ + { UdfFieldId: 'referral', Value: ' ' }, + { UdfFieldId: 'language', Value: '' }, + { UdfFieldId: 'coresponse', Value: 'false' }, + ]); + expect(missing.map((f) => f.UdfFieldId)).toEqual(['referral', 'mrn']); + }); + + it('is satisfied once every required field has a value', () => { + expect( + missingRequiredUdfFields(fields, [ + { UdfFieldId: 'referral', Value: '988' }, + { UdfFieldId: 'coresponse', Value: 'true' }, + { UdfFieldId: 'mrn', Value: 'A-1' }, + ]) + ).toEqual([]); + }); +}); diff --git a/src/lib/udf/options.ts b/src/lib/udf/options.ts index 761c1aa9..b2e5bf9d 100644 --- a/src/lib/udf/options.ts +++ b/src/lib/udf/options.ts @@ -103,6 +103,18 @@ export const filterComboSuggestions = (options: UdfOption[], text: string | null return options.filter((o) => o.label.toLowerCase().includes(query)); }; +/** + * The fields a form shows: enabled ones meant for the apps, plus any the department requires wherever it is hidden, + * since a call cannot be saved without them. + */ +export const isUdfFieldShown = (field: { IsEnabled: boolean; IsVisibleOnMobile: boolean; IsRequired: boolean }): boolean => field.IsEnabled && (field.IsVisibleOnMobile || field.IsRequired); + +/** Required fields left blank. Whitespace is blank, as on the server, which refuses the save the same way. */ +export const missingRequiredUdfFields = (fields: readonly T[], values: readonly { UdfFieldId: string; Value: string }[]): T[] => { + const valueById = new Map(values.map((v) => [v.UdfFieldId, v.Value])); + return fields.filter((f) => f.IsRequired && !(valueById.get(f.UdfFieldId) ?? '').trim()); +}; + /** The keys a stored dropdown or multi-select value selects. */ export const selectedOptionKeys = (fieldDataType: number, value: string | null | undefined): string[] => { const text = (value ?? '').trim(); diff --git a/src/lib/unit-map-focus.ts b/src/lib/unit-map-focus.ts new file mode 100644 index 00000000..c8a8c1f3 --- /dev/null +++ b/src/lib/unit-map-focus.ts @@ -0,0 +1,176 @@ +import { getPinEntityId, MapPinType } from '@/lib/map-pin-ids'; +import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; +import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; + +/** + * What the console map frames while a unit is selected: the unit's own pin and the pins of the members seated in its + * roles. Either may have no position: a team whose members carry the app may never run the Unit app, so it is framed + * on its crew alone. + */ +export interface MapFocus { + /** Identifies what is focused ("unit:12"); a new key always re-frames. */ + key: string; + /** The unit's name, for the map's focus banner. */ + label: string; + /** The unit's pin id, lowercased ("u12"). */ + unitPinId: string; + /** The seated members' pin ids, lowercased ("p"), in the unit's role order, one per person. */ + crewPinIds: string[]; +} + +export interface MapFocusPoint { + pinId: string; + /** [longitude, latitude], the order Mapbox takes. */ + coordinate: [number, number]; +} + +export interface MapFocusPoints { + /** Every focused pin that has a position: the unit first, then its crew. */ + points: MapFocusPoint[]; + unitLocated: boolean; + crewLocated: number; + crewTotal: number; +} + +/** + * The narrowest view a frame may have, in degrees (about 250 m of latitude). A lone member, or a crew standing + * together, is framed as close as this: near enough to read the street, without zooming into a blank tile. + */ +export const FOCUS_MIN_SPAN_DEGREES = 0.0025; + +/** The web map's cap on top of the minimum span, so it agrees with the native frame. */ +export const FOCUS_MAX_ZOOM = 17; + +export const unitFocusPinId = (unitId: string | number): string => `u${unitId}`.toLowerCase(); + +export const personFocusPinId = (userId: string): string => `p${userId}`.toLowerCase(); + +/** The pin ids of the members seated in the unit's roles, one per person even when someone holds two seats. */ +export const getCrewFocusPinIds = (unit: Pick | null | undefined): string[] => { + const pinIds: string[] = []; + + for (const role of unit?.Roles ?? []) { + const userId = (role?.UserId ?? '').trim(); + if (!userId) continue; + + const pinId = personFocusPinId(userId); + if (!pinIds.includes(pinId)) pinIds.push(pinId); + } + + return pinIds; +}; + +export const createUnitMapFocus = (unitId: string, label: string, crewPinIds: string[]): MapFocus => ({ + key: `unit:${unitId}`, + label, + unitPinId: unitFocusPinId(unitId), + crewPinIds, +}); + +/** The pin's focus id in the same lowercased, prefixed form, also for a server that sends bare ids. */ +const focusIdForPin = (pin: MapMakerInfoData): string | null => { + if (pin.Type === MapPinType.Unit) return unitFocusPinId(getPinEntityId(pin)); + if (pin.Type === MapPinType.Personnel) return personFocusPinId(getPinEntityId(pin)); + return null; +}; + +// Pins come off the network (and the live feed); fitBounds throws on an out-of-range latitude. +const usable = (pin: MapMakerInfoData): boolean => + Number.isFinite(pin.Latitude) && Number.isFinite(pin.Longitude) && Math.abs(pin.Latitude) <= 90 && Math.abs(pin.Longitude) <= 180 && !(pin.Latitude === 0 && pin.Longitude === 0); + +/** Where the focused unit and crew are on the map now, from the pins it shows (live positions already applied). */ +export const resolveMapFocusPoints = (pins: MapMakerInfoData[], focus: MapFocus): MapFocusPoints => { + const positions = new Map(); + + for (const pin of pins) { + if (pin.Type !== MapPinType.Unit && pin.Type !== MapPinType.Personnel) continue; + if (!usable(pin)) continue; + + const focusId = focusIdForPin(pin); + if (focusId && !positions.has(focusId)) positions.set(focusId, [pin.Longitude, pin.Latitude]); + } + + const points: MapFocusPoint[] = []; + const unitCoordinate = positions.get(focus.unitPinId); + if (unitCoordinate) points.push({ pinId: focus.unitPinId, coordinate: unitCoordinate }); + + let crewLocated = 0; + for (const pinId of focus.crewPinIds) { + const coordinate = positions.get(pinId); + if (!coordinate) continue; + points.push({ pinId, coordinate }); + crewLocated++; + } + + return { points, unitLocated: !!unitCoordinate, crewLocated, crewTotal: focus.crewPinIds.length }; +}; + +/** + * South-west and north-east corners around the points, widened to at least `minSpan` degrees each way so one point + * (or several on top of each other) still frames a street, not a single tile; null when there are no points. + */ +export const boundsForFocusPoints = (points: [number, number][], minSpan: number = FOCUS_MIN_SPAN_DEGREES): { sw: [number, number]; ne: [number, number] } | null => { + if (points.length === 0) return null; + + let minLng = points[0][0]; + let maxLng = points[0][0]; + let minLat = points[0][1]; + let maxLat = points[0][1]; + for (const [lng, lat] of points) { + minLng = Math.min(minLng, lng); + maxLng = Math.max(maxLng, lng); + minLat = Math.min(minLat, lat); + maxLat = Math.max(maxLat, lat); + } + + const widen = (min: number, max: number, limit: number): [number, number] => { + const missing = minSpan - (max - min); + if (missing <= 0) return [min, max]; + return [Math.max(-limit, min - missing / 2), Math.min(limit, max + missing / 2)]; + }; + + const [west, east] = widen(minLng, maxLng, 180); + const [south, north] = widen(minLat, maxLat, 90); + + return { sw: [west, south], ne: [east, north] }; +}; + +/** + * Changes when the frame should change on its own: another unit, someone joining or leaving the crew, or a member + * starting or stopping reporting. A position moving does not change it. + */ +export const focusFrameKey = (focus: MapFocus, located: MapFocusPoints): string => + `${focus.key}|${located.points + .map((point) => point.pinId) + .sort() + .join(',')}`; + +export interface VisibleBounds { + contains: (coordinate: [number, number]) => boolean; +} + +/** A visible-bounds test from the native map's [[neLng, neLat], [swLng, swLat]]. */ +export const visibleBoundsFromCorners = (corners: number[][] | null | undefined): VisibleBounds | null => { + if (!corners || corners.length < 2) return null; + + const [ne, sw] = corners; + const east = ne[0]; + const north = ne[1]; + const west = sw[0]; + const south = sw[1]; + + return { + contains: ([lng, lat]) => lat >= south && lat <= north && (west <= east ? lng >= west && lng <= east : lng >= west || lng <= east), + }; +}; + +/** + * True when a focused member who was on screen has moved off it. Someone the dispatcher already panned away from does + * not count, so following the crew never undoes a deliberate look elsewhere. + */ +export const hasPointLeftView = (previous: Map, points: MapFocusPoint[], visible: VisibleBounds): boolean => + points.some((point) => { + const before = previous.get(point.pinId); + if (!before || (before[0] === point.coordinate[0] && before[1] === point.coordinate[1])) return false; + return visible.contains(before) && !visible.contains(point.coordinate); + }); diff --git a/src/models/v4/calls/archivedCallsResult.ts b/src/models/v4/calls/archivedCallsResult.ts new file mode 100644 index 00000000..2d6521ae --- /dev/null +++ b/src/models/v4/calls/archivedCallsResult.ts @@ -0,0 +1,7 @@ +import { BaseV4Request } from '../baseV4Request'; +import { type CallResultData } from './callResultData'; + +/** Response of `/Calls/GetArchivedCalls`: the closed calls (States 1-7) logged in one year, newest first. */ +export class ArchivedCallsResult extends BaseV4Request { + public Data: CallResultData[] = []; +} diff --git a/src/models/v4/calls/callResultData.ts b/src/models/v4/calls/callResultData.ts index af088baa..407d8d10 100644 --- a/src/models/v4/calls/callResultData.ts +++ b/src/models/v4/calls/callResultData.ts @@ -47,6 +47,12 @@ export class CallResultData { /** The run card driving this call's dispatch, or null when no card matched. */ public ActiveRunCardId: number | null = null; public LoggedOnUtc: string = ''; + /** When the call was closed, in the department's time; null while it is open, undefined from older servers. */ + public ClosedOn?: string | null; + /** When the call was closed, UTC; null while it is open, undefined from older servers. */ + public ClosedOnUtc?: string | null; + /** Who closed the call; null while it is open, undefined from older servers. */ + public ClosedByUserId?: string | null; public DispatchedOn: string = ''; public DispatchedOnUtc: string = ''; public ScheduledOn: string = ''; diff --git a/src/models/v4/calls/callYearsResult.ts b/src/models/v4/calls/callYearsResult.ts new file mode 100644 index 00000000..b7aecaea --- /dev/null +++ b/src/models/v4/calls/callYearsResult.ts @@ -0,0 +1,6 @@ +import { BaseV4Request } from '../baseV4Request'; + +/** Response of `/Calls/GetCallYears`: the years (e.g. "2026") the department has calls in, newest first. */ +export class CallYearsResult extends BaseV4Request { + public Data: string[] = []; +} diff --git a/src/stores/calls/__tests__/archived-store.test.ts b/src/stores/calls/__tests__/archived-store.test.ts new file mode 100644 index 00000000..8727c0a0 --- /dev/null +++ b/src/stores/calls/__tests__/archived-store.test.ts @@ -0,0 +1,175 @@ +import { act } from '@testing-library/react-native'; + +import { getArchivedCalls, getCallYears } from '@/api/calls/calls'; +import { type CallResultData } from '@/models/v4/calls/callResultData'; + +import { useArchivedCallsStore } from '../archived-store'; + +jest.mock('@/api/calls/calls', () => ({ + getArchivedCalls: jest.fn(), + getCallYears: jest.fn(), +})); +jest.mock('@/lib/logging', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const mockGetCallYears = getCallYears as jest.MockedFunction; +const mockGetArchivedCalls = getArchivedCalls as jest.MockedFunction; + +const archivedCall = (callId: string): CallResultData => ({ CallId: callId, Number: `26-${callId}`, Name: 'Fire', State: 1 }) as CallResultData; + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +}; + +describe('useArchivedCallsStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + useArchivedCallsStore.getState().reset(); + }); + + it('loads the years and then the newest year with calls', async () => { + mockGetCallYears.mockResolvedValueOnce({ Data: ['2026', '2025'], Status: 'Success' } as never); + mockGetArchivedCalls.mockResolvedValueOnce({ Data: [archivedCall('1'), archivedCall('2')], Status: 'Success' } as never); + + await act(async () => { + await useArchivedCallsStore.getState().load(); + }); + + const state = useArchivedCallsStore.getState(); + expect(mockGetArchivedCalls).toHaveBeenCalledWith('2026'); + expect(state.years).toEqual(['2026', '2025']); + expect(state.selectedYear).toBe('2026'); + expect(state.archivedCalls.map((c) => c.CallId)).toEqual(['1', '2']); + expect(state.isLoading).toBe(false); + expect(state.error).toBeNull(); + }); + + it('falls back to the current year when the department has no calls', async () => { + mockGetCallYears.mockResolvedValueOnce({ Data: undefined, Status: 'NotFound' } as never); + mockGetArchivedCalls.mockResolvedValueOnce({ Data: undefined, Status: 'NotFound' } as never); + + await act(async () => { + await useArchivedCallsStore.getState().load(); + }); + + const year = String(new Date().getFullYear()); + expect(useArchivedCallsStore.getState().selectedYear).toBe(year); + expect(useArchivedCallsStore.getState().years).toEqual([year]); + expect(useArchivedCallsStore.getState().archivedCalls).toEqual([]); + }); + + it('keeps the selected year across loads while it is still listed', async () => { + useArchivedCallsStore.setState({ selectedYear: '2025' }); + mockGetCallYears.mockResolvedValueOnce({ Data: ['2026', '2025'], Status: 'Success' } as never); + mockGetArchivedCalls.mockResolvedValueOnce({ Data: [], Status: 'Success' } as never); + + await act(async () => { + await useArchivedCallsStore.getState().load(); + }); + + expect(mockGetArchivedCalls).toHaveBeenCalledWith('2025'); + }); + + it('opens on the year showYear picked, for the call just added', async () => { + useArchivedCallsStore.getState().showYear('2019'); + mockGetCallYears.mockResolvedValueOnce({ Data: ['2026', '2019'], Status: 'Success' } as never); + mockGetArchivedCalls.mockResolvedValueOnce({ Data: [archivedCall('7')], Status: 'Success' } as never); + + await act(async () => { + await useArchivedCallsStore.getState().load(); + }); + + expect(mockGetArchivedCalls).toHaveBeenCalledWith('2019'); + expect(useArchivedCallsStore.getState().archivedCalls.map((c) => c.CallId)).toEqual(['7']); + }); + + it('marks the year already showing for a reload when a call is added to it', () => { + useArchivedCallsStore.setState({ selectedYear: '2026', years: ['2026', '2025'], archivedCalls: [archivedCall('1')], lastFetched: Date.now() }); + + useArchivedCallsStore.getState().showYear('2026'); + + const state = useArchivedCallsStore.getState(); + expect(state.selectedYear).toBe('2026'); + expect(state.lastFetched).toBe(0); + // Its rows stay up until the reload replaces them. + expect(state.archivedCalls.map((c) => c.CallId)).toEqual(['1']); + }); + + it("drops another year's rows when showYear moves to a different year", () => { + useArchivedCallsStore.setState({ selectedYear: '2026', years: ['2026', '2025'], archivedCalls: [archivedCall('1')], lastFetched: Date.now() }); + + useArchivedCallsStore.getState().showYear('2019'); + + const state = useArchivedCallsStore.getState(); + expect(state.selectedYear).toBe('2019'); + expect(state.years).toEqual(['2026', '2025', '2019']); + expect(state.archivedCalls).toEqual([]); + expect(state.lastFetched).toBe(0); + }); + + it('reports a failed year lookup without loading calls', async () => { + mockGetCallYears.mockRejectedValueOnce(new Error('Network down')); + + await act(async () => { + await useArchivedCallsStore.getState().load(); + }); + + expect(mockGetArchivedCalls).not.toHaveBeenCalled(); + expect(useArchivedCallsStore.getState().error).toBe('Network down'); + expect(useArchivedCallsStore.getState().isLoading).toBe(false); + }); + + it('keeps the rows and reports the error when a refresh fails', async () => { + useArchivedCallsStore.setState({ selectedYear: '2026', archivedCalls: [archivedCall('1')] }); + mockGetArchivedCalls.mockRejectedValueOnce(new Error('Server error')); + + await act(async () => { + await useArchivedCallsStore.getState().fetchArchivedCalls(); + }); + + expect(useArchivedCallsStore.getState().archivedCalls.map((c) => c.CallId)).toEqual(['1']); + expect(useArchivedCallsStore.getState().error).toBe('Server error'); + }); + + it("never shows an older year's calls after a quicker switch to another year", async () => { + useArchivedCallsStore.setState({ selectedYear: '2026', years: ['2026', '2025', '2024'], lastFetched: 1 }); + const slow = deferred(); + mockGetArchivedCalls.mockReturnValueOnce(slow.promise as never).mockResolvedValueOnce({ Data: [archivedCall('24')], Status: 'Success' } as never); + + let first!: Promise; + await act(async () => { + first = useArchivedCallsStore.getState().selectYear('2025'); + await useArchivedCallsStore.getState().selectYear('2024'); + }); + await act(async () => { + slow.resolve({ Data: [archivedCall('25')], Status: 'Success' }); + await first; + }); + + expect(useArchivedCallsStore.getState().selectedYear).toBe('2024'); + expect(useArchivedCallsStore.getState().archivedCalls.map((c) => c.CallId)).toEqual(['24']); + expect(useArchivedCallsStore.getState().isLoading).toBe(false); + }); + + it('clears the previous year while the selected one loads', async () => { + useArchivedCallsStore.setState({ selectedYear: '2026', years: ['2026', '2025'], archivedCalls: [archivedCall('1')], lastFetched: 1 }); + const pending = deferred(); + mockGetArchivedCalls.mockReturnValueOnce(pending.promise as never); + + let switching!: Promise; + act(() => { + switching = useArchivedCallsStore.getState().selectYear('2025'); + }); + + expect(useArchivedCallsStore.getState().archivedCalls).toEqual([]); + expect(useArchivedCallsStore.getState().isLoading).toBe(true); + + await act(async () => { + pending.resolve({ Data: [archivedCall('2')], Status: 'Success' }); + await switching; + }); + }); +}); diff --git a/src/stores/calls/archived-store.ts b/src/stores/calls/archived-store.ts new file mode 100644 index 00000000..fddd9d4f --- /dev/null +++ b/src/stores/calls/archived-store.ts @@ -0,0 +1,112 @@ +import { create } from 'zustand'; + +import { getArchivedCalls, getCallYears } from '@/api/calls/calls'; +import { logger } from '@/lib/logging'; +import { type CallResultData } from '@/models/v4/calls/callResultData'; + +interface ArchivedCallsState { + /** Years the department has calls in, newest first; the selected year is always among them. */ + years: string[]; + /** The year whose archived calls are loaded; null until the years have loaded. */ + selectedYear: string | null; + /** Closed calls logged in the selected year, newest first. */ + archivedCalls: CallResultData[]; + isLoading: boolean; + error: string | null; + lastFetched: number; + + /** + * Loads the years, then the archived calls of `preferredYear`, else the year already selected, else the newest + * year with calls (the current year when there are none). + */ + load: (preferredYear?: string) => Promise; + selectYear: (year: string) => Promise; + /** Makes `year` the selected year without fetching, for the list to load when it next comes into view. */ + showYear: (year: string) => void; + fetchArchivedCalls: () => Promise; + reset: () => void; +} + +const initialState = { + years: [] as string[], + selectedYear: null as string | null, + archivedCalls: [] as CallResultData[], + isLoading: false, + error: null as string | null, + lastFetched: 0, +}; + +/** The year list with `year` in its place (newest first), for a year the server has not reported calls in yet. */ +const withYear = (years: string[], year: string): string[] => (years.includes(year) ? years : [...years, year].sort((a, b) => Number(b) - Number(a))); + +const errorMessageOf = (error: unknown, fallback: string): string => (error instanceof Error ? error.message : fallback); + +// Each fetch takes a number; only the newest one may write, so switching years quickly never shows an older year's calls. +let latestRequest = 0; + +export const useArchivedCallsStore = create((set, get) => ({ + ...initialState, + + load: async (preferredYear?: string) => { + const request = ++latestRequest; + set({ isLoading: true, error: null }); + + let years: string[]; + try { + const response = await getCallYears(); + // "NotFound" with an empty list: the department has no calls yet. + years = (response?.Data ?? []).map(String); + } catch (error) { + logger.error({ message: 'Failed to fetch call years', context: { error } }); + if (request === latestRequest) set({ error: errorMessageOf(error, 'Failed to fetch call years'), isLoading: false }); + return; + } + + if (request !== latestRequest) return; + + const current = get().selectedYear; + const year = preferredYear || (current && years.includes(current) ? current : years[0]) || String(new Date().getFullYear()); + set({ years: withYear(years, year), selectedYear: year }); + + await get().fetchArchivedCalls(); + }, + + selectYear: async (year: string) => { + if (year === get().selectedYear && get().lastFetched > 0) return; + + // The previous year's rows would read as this year's while the new ones load. + set((state) => ({ selectedYear: year, years: withYear(state.years, year), archivedCalls: [] })); + await get().fetchArchivedCalls(); + }, + + showYear: (year: string) => { + // Stale even when it is the year already showing: those rows are from before the call just added. Another + // year's rows would read as this year's until the load. + set((state) => ({ selectedYear: year, years: withYear(state.years, year), archivedCalls: year === state.selectedYear ? state.archivedCalls : [], lastFetched: 0 })); + }, + + fetchArchivedCalls: async () => { + const year = get().selectedYear; + if (!year) return; + + const request = ++latestRequest; + set({ isLoading: true, error: null }); + + try { + const response = await getArchivedCalls(year); + if (request !== latestRequest) return; + + set({ archivedCalls: response?.Data ?? [], isLoading: false, lastFetched: Date.now() }); + } catch (error) { + logger.error({ message: 'Failed to fetch archived calls', context: { error, year } }); + if (request !== latestRequest) return; + + set({ error: errorMessageOf(error, 'Failed to fetch archived calls'), isLoading: false }); + } + }, + + reset: () => { + latestRequest++; + set(initialState); + }, +})); diff --git a/src/stores/signalr/__tests__/signalr-store.unit-active-roles.test.ts b/src/stores/signalr/__tests__/signalr-store.unit-active-roles.test.ts new file mode 100644 index 00000000..06cbb0e1 --- /dev/null +++ b/src/stores/signalr/__tests__/signalr-store.unit-active-roles.test.ts @@ -0,0 +1,103 @@ +// Hub events are delivered through the service's event bus; this stand-in keeps real listeners so the tests can +// raise `unitActiveRolesUpdated` exactly as the service would. +const mockListeners = new Map void>>(); +const mockEmit = (event: string, ...args: unknown[]) => { + Array.from(mockListeners.get(event) ?? []).forEach((listener) => listener(...args)); +}; + +jest.mock('@/services/signalr.service', () => { + const instance = { + connectToHubWithEventingUrl: jest.fn().mockResolvedValue(undefined), + disconnectFromHub: jest.fn().mockResolvedValue(undefined), + invoke: jest.fn().mockResolvedValue(undefined), + on: jest.fn((event: string, listener: (...args: unknown[]) => void) => { + if (!mockListeners.has(event)) mockListeners.set(event, new Set()); + mockListeners.get(event)!.add(listener); + }), + off: jest.fn((event: string, listener: (...args: unknown[]) => void) => { + mockListeners.get(event)?.delete(listener); + }), + isHubConnected: jest.fn(() => true), + getTotalEventListenerCount: jest.fn(() => 0), + }; + return { + signalRService: instance, + SignalRService: { + HUB_CONNECTED_EVENT: '__hubConnected', + HUB_RECONNECTED_EVENT: '__hubReconnected', + HUB_RECONNECTING_EVENT: '__hubReconnecting', + HUB_DISCONNECTED_EVENT: '__hubDisconnected', + }, + }; +}); + +jest.mock('../../app/core-store', () => ({ + useCoreStore: { + getState: () => ({ config: { EventingUrl: 'https://eventing.example.com/' } }), + }, +})); + +jest.mock('../../security/store', () => ({ + securityStore: { getState: () => ({ rights: { DepartmentId: '123' } }) }, + useSecurityStore: { getState: () => ({ rights: { DepartmentId: '123' } }) }, +})); + +jest.mock('@/lib/logging', () => ({ + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }, +})); + +jest.mock('@/lib/env', () => ({ + Env: { + CHANNEL_HUB_NAME: 'eventingHub', + REALTIME_GEO_HUB_NAME: 'geolocationHub', + CHAT_HUB_NAME: 'chatHub', + }, +})); + +jest.mock('@/lib', () => ({ + useAuthStore: { getState: jest.fn(() => ({ accessToken: 'mock-token' })) }, +})); + +type Store = typeof import('../signalr-store').useSignalRStore; + +describe('useSignalRStore unitActiveRolesUpdated', () => { + let useSignalRStore: Store; + + beforeEach(() => { + jest.resetModules(); + mockListeners.clear(); + useSignalRStore = require('../signalr-store').useSignalRStore; + }); + + afterEach(async () => { + await useSignalRStore.getState().disconnectUpdateHub(); + }); + + it('subscribes to the hub method on the update hub', async () => { + await useSignalRStore.getState().connectUpdateHub(); + + const { signalRService } = require('@/services/signalr.service'); + expect(signalRService.connectToHubWithEventingUrl).toHaveBeenCalledWith(expect.objectContaining({ methods: expect.arrayContaining(['unitActiveRolesUpdated']) })); + expect(mockListeners.get('unitActiveRolesUpdated')?.size).toBe(1); + }); + + it('marks the units as changed so the console refetches their crews, and the live maps their pins', async () => { + await useSignalRStore.getState().connectUpdateHub(); + const before = useSignalRStore.getState(); + + mockEmit('unitActiveRolesUpdated', '12'); + + const after = useSignalRStore.getState(); + expect(after.lastUnitsUpdateTimestamp).toBeGreaterThan(0); + expect(after.lastUnitsUpdateTimestamp).toBeGreaterThanOrEqual(before.lastUnitsUpdateTimestamp); + expect(after.lastUpdateTimestamp).toBeGreaterThan(0); + expect(after.lastEventType).toBe('unitActiveRolesUpdated'); + }); + + it('stops listening when the update hub disconnects', async () => { + await useSignalRStore.getState().connectUpdateHub(); + await useSignalRStore.getState().disconnectUpdateHub(); + + expect(mockListeners.get('unitActiveRolesUpdated')?.size ?? 0).toBe(0); + }); +}); diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index 23c5daf6..b9d257b5 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -191,6 +191,7 @@ export type SignalREventType = | 'personnelStatusUpdated' | 'personnelStaffingUpdated' | 'unitStatusUpdated' + | 'unitActiveRolesUpdated' | 'callsUpdated' | 'callAdded' | 'callClosed' @@ -280,6 +281,8 @@ interface EventHandlers { personnelStatusUpdated: ((data: unknown) => void) | null; personnelStaffingUpdated: ((data: unknown) => void) | null; unitStatusUpdated: ((data: unknown) => void) | null; + /** A unit's seated crew changed: (unit id, or "0" when several units changed at once). */ + unitActiveRolesUpdated: ((data: unknown) => void) | null; callsUpdated: ((data: unknown) => void) | null; callAdded: ((data: unknown) => void) | null; callClosed: ((data: unknown) => void) | null; @@ -298,6 +301,7 @@ let updateHubHandlers: EventHandlers = { personnelStatusUpdated: null, personnelStaffingUpdated: null, unitStatusUpdated: null, + unitActiveRolesUpdated: null, callsUpdated: null, callAdded: null, callClosed: null, @@ -375,6 +379,7 @@ function unregisterUpdateHubHandlers(): void { 'personnelStatusUpdated', 'personnelStaffingUpdated', 'unitStatusUpdated', + 'unitActiveRolesUpdated', 'callsUpdated', 'callAdded', 'callClosed', @@ -502,6 +507,7 @@ export const useSignalRStore = create((set, get) => ({ 'personnelStatusUpdated', 'personnelStaffingUpdated', 'unitStatusUpdated', + 'unitActiveRolesUpdated', 'callsUpdated', 'callAdded', 'callClosed', @@ -557,6 +563,18 @@ export const useSignalRStore = create((set, get) => ({ }; signalRService.on('unitStatusUpdated', updateHubHandlers.unitStatusUpdated); + // Who rides a unit changed (seats saved from the Unit app, the web staffing page, or a unit deleted). The units + // refetch brings the new Roles, so the console map re-frames a selected unit on its new crew; the pin refetch + // brings the "Name (Unit)" labels. + updateHubHandlers.unitActiveRolesUpdated = (message: unknown) => { + logger.info({ + message: 'unitActiveRolesUpdated', + context: { message }, + }); + set({ lastUpdateMessage: null, lastUpdateTimestamp: Date.now(), lastEventType: 'unitActiveRolesUpdated', lastUnitsUpdateTimestamp: Date.now() }); + }; + signalRService.on('unitActiveRolesUpdated', updateHubHandlers.unitActiveRolesUpdated); + updateHubHandlers.callsUpdated = (message: unknown) => { const now = Date.now(); logger.info({ diff --git a/src/translations/ar.json b/src/translations/ar.json index 37f8c9d9..a1f394f5 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -240,13 +240,8 @@ "contact_phone": "هاتف جهة الاتصال", "contact_phone_placeholder": "أدخل هاتف جهة الاتصال", "coordinates": "إحداثيات GPS", - "coordinates_found": "تم العثور على الإحداثيات وتحديث العنوان", - "coordinates_geocoding_error": "فشل في الحصول على العنوان للإحداثيات، لكن تم تعيين الموقع على الخريطة", - "coordinates_invalid_format": "تنسيق الإحداثيات غير صالح. يرجى استخدام التنسيق: خط العرض، خط الطول", - "coordinates_no_address": "تم تعيين الإحداثيات على الخريطة، لكن لم يتم العثور على عنوان", - "coordinates_out_of_range": "الإحداثيات خارج النطاق. خط العرض يجب أن يكون بين -90 و 90، خط الطول بين -180 و 180", - "coordinates_placeholder": "أدخل إحداثيات GPS (مثال: 37.7749، -122.4194)", - "coordinates_required": "يرجى إدخال إحداثيات للبحث", + "coordinates_incomplete": "أدخل خط العرض وخط الطول معًا", + "coordinates_invalid": "تحقق من خط العرض وخط الطول: كلاهما مطلوب بالدرجات العشرية", "create": "إنشاء", "create_error": "خطأ في إنشاء المكالمة", "create_new_call": "إنشاء مكالمة جديدة", @@ -273,9 +268,16 @@ "indoor_location": "الموقع الداخلي", "invalid_priority": "أولوية غير صحيحة محددة. يرجى اختيار أولوية صحيحة.", "invalid_type": "نوع غير صحيح محدد. يرجى اختيار نوع مكالمة صحيح.", + "latitude": "خط العرض", + "latitude_invalid": "أدخل خط عرض بالدرجات العشرية بين -90 و 90", + "latitude_placeholder": "مثال: 39.122639", "loading": "جاري تحميل المكالمات...", "loading_calls": "جاري تحميل المكالمات...", "loading_destination_pois": "جارٍ تحميل نقاط اهتمام الوجهة...", + "longitude": "خط الطول", + "longitude_invalid": "أدخل خط طول بالدرجات العشرية بين -180 و 180", + "longitude_placeholder": "مثال: -119.758773", + "looking_up_address": "جارٍ البحث عن العنوان لهذه الإحداثيات...", "name": "الاسم", "name_placeholder": "أدخل اسم المكالمة", "nature": "الطبيعة", @@ -323,6 +325,7 @@ "selected": "مختار", "title": "المكالمات", "type": "النوع", + "udf_select_placeholder": "اختر {{field}}", "udf_suggestions": "اقتراحات", "units": "الوحدات", "users": "المستخدمين", @@ -798,6 +801,11 @@ "call_selected": "تم تحديد المكالمة", "unit_selected": "تم تحديد الوحدة", "unit_deselected": "تم إلغاء تحديد الوحدة", + "unit_map_focus_crew_located": "تم تحديد موقع {{located}} من {{total}} من أفراد الطاقم", + "unit_map_focus_no_crew": "لم يُعيَّن طاقم", + "unit_map_focus_no_unit_position": "الوحدة لا تُبلغ عن موقعها", + "unit_map_focus_no_positions": "لا توجد مواقع لهذه الوحدة أو طاقمها", + "unit_map_focus_recenter": "التوسيط على الوحدة", "personnel_selected": "تم تحديد الموظف", "personnel_deselected": "تم إلغاء تحديد الموظف", "loading_map": "جاري تحميل الخريطة...", @@ -1193,7 +1201,8 @@ "chat": "الدردشة", "assistant": "المساعد", "pending_calls": "المكالمات المعلقة", - "awaiting_resources": "بانتظار الموارد" + "awaiting_resources": "بانتظار الموارد", + "archived_calls": "المكالمات المؤرشفة" }, "mfa": { "account": { @@ -1935,6 +1944,65 @@ "table_address": "العنوان", "table_scheduled": "مجدول في" }, + "archived_calls": { + "title": "المكالمات المؤرشفة", + "loading": "جارٍ تحميل المكالمات المؤرشفة...", + "no_archived_calls": "لا توجد مكالمات مؤرشفة", + "no_archived_calls_description": "لا توجد مكالمات مغلقة لعام {{year}}.", + "no_matches": "لا توجد مكالمات مطابقة", + "no_matches_description": "لا توجد مكالمات مؤرشفة تطابق البحث وعوامل التصفية.", + "search": "البحث في المكالمات المؤرشفة...", + "year": "السنة", + "filters": "عوامل التصفية", + "clear_filters": "مسح عوامل التصفية", + "logged_between": "سُجّلت بين", + "from_date": "من", + "to_date": "إلى", + "showing": "{{count}} من {{total}} مكالمة", + "table_state": "أُغلقت كـ", + "table_logged": "تاريخ التسجيل", + "table_closed": "تاريخ الإغلاق", + "add": "إضافة مكالمة مؤرشفة", + "add_title": "إضافة مكالمة مؤرشفة", + "add_description": "أدخل مكالمة حدثت بالفعل. تُحفظ على أنها مغلقة ولا يتم إشعار أحد.", + "section_call": "المكالمة", + "section_closing": "التسلسل الزمني والإغلاق", + "logged_on": "تاريخ التسجيل", + "closed_on": "تاريخ الإغلاق", + "closed_on_helper": "اتركه فارغًا لاستخدام الوقت الحالي.", + "closing_notes": "ملاحظات الإغلاق", + "closing_notes_placeholder": "كيف تم حل المكالمة", + "section_location": "الموقع", + "address_helper": "بدون إحداثيات، يتم تحديد موقع العنوان على الخريطة.", + "latitude": "خط العرض", + "longitude": "خط الطول", + "section_contact": "جهة الاتصال والمعرّفات", + "reference_id": "المعرّف المرجعي", + "external_id": "المعرّف الخارجي", + "no_type": "بدون نوع", + "section_dispatched": "الموارد المُرسلة", + "dispatched_hint": "يُسجَّل على المكالمة فقط. لا يتم إشعار أحد.", + "select_dispatched": "اختيار الموارد", + "dispatched_none": "لم يُسجَّل شيء", + "dispatched_everyone": "الجميع", + "dispatched_summary": "الأفراد: {{users}}، المجموعات: {{groups}}، الأدوار: {{roles}}، الوحدات: {{units}}", + "section_numbering": "ترقيم المكالمات", + "recalculate_numbers": "إعادة حساب أرقام المكالمات", + "recalculate_numbers_hint": "يعيد ترقيم مكالمات السنة حسب ترتيب تسجيلها، لتأخذ هذه المكالمة مكانها في التسلسل.", + "save": "حفظ المكالمة المؤرشفة", + "save_success": "تم حفظ المكالمة المؤرشفة", + "save_error": "فشل حفظ المكالمة المؤرشفة", + "name_required": "الاسم مطلوب", + "nature_required": "طبيعة المكالمة مطلوبة", + "priority_required": "اختر أولوية", + "state_required": "اختر كيف أُغلقت المكالمة", + "logged_on_required": "أدخل وقت تسجيل المكالمة", + "logged_on_future": "لا يمكن تسجيل المكالمة في المستقبل", + "closed_on_invalid": "أدخل وقت إغلاق صالحًا", + "closed_on_before_logged": "لا يمكن إغلاق المكالمة قبل تسجيلها", + "closed_on_future": "لا يمكن إغلاق المكالمة في المستقبل", + "coordinates_invalid": "أدخل خطي العرض والطول بالدرجات العشرية، أو اترك كليهما فارغين" + }, "pending_calls": { "title": "المكالمات المعلقة", "loading": "تحميل المكالمات المعلقة...", diff --git a/src/translations/de.json b/src/translations/de.json index 4c139329..a5324b9f 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -225,6 +225,10 @@ "destination": "Ziel", "destination_poi": "Ziel-POI", "loading_destination_pois": "Ziel-POIs werden geladen...", + "longitude": "Längengrad", + "longitude_invalid": "Geben Sie einen Längengrad in Dezimalgrad zwischen -180 und 180 ein", + "longitude_placeholder": "z. B. -119.758773", + "looking_up_address": "Adresse für diese Koordinaten wird gesucht...", "no_destination": "Kein Ziel", "no_destination_pois_available": "Keine Ziel-POIs verfügbar", "select_destination_poi": "Ziel-POI auswählen", @@ -246,13 +250,8 @@ "contact_phone": "Kontakttelefon", "contact_phone_placeholder": "Geben Sie die Telefonnummer des Kontakts ein", "coordinates": "GPS-Koordinaten", - "coordinates_found": "Koordinaten gefunden und Adresse aktualisiert", - "coordinates_geocoding_error": "Adresse für Koordinaten konnte nicht ermittelt werden, Standort aber auf Karte gesetzt", - "coordinates_invalid_format": "Ungültiges Koordinatenformat. Bitte verwenden Sie das Format: Breitengrad, Längengrad", - "coordinates_no_address": "Koordinaten auf Karte gesetzt, aber keine Adresse gefunden", - "coordinates_out_of_range": "Koordinaten außerhalb des Bereichs. Breitengrad muss -90 bis 90 sein, Längengrad -180 bis 180", - "coordinates_placeholder": "GPS-Koordinaten eingeben (z. B. 37.7749, -122.4194)", - "coordinates_required": "Bitte geben Sie Koordinaten für die Suche ein", + "coordinates_incomplete": "Geben Sie Breiten- und Längengrad ein", + "coordinates_invalid": "Prüfen Sie Breiten- und Längengrad: Beide werden in Dezimalgrad benötigt", "create": "Erstellen", "create_error": "Fehler beim Erstellen des Einsatzes", "create_new_call": "Neuen Einsatz erstellen", @@ -277,6 +276,9 @@ "indoor_location": "Innenstandort", "invalid_priority": "Ungültige Priorität ausgewählt. Bitte wählen Sie eine gültige Priorität.", "invalid_type": "Ungültiger Typ ausgewählt. Bitte wählen Sie einen gültigen Einsatztyp.", + "latitude": "Breitengrad", + "latitude_invalid": "Geben Sie einen Breitengrad in Dezimalgrad zwischen -90 und 90 ein", + "latitude_placeholder": "z. B. 39.122639", "loading": "Einsätze werden geladen...", "loading_calls": "Einsätze werden geladen...", "name": "Name", @@ -323,6 +325,7 @@ "selected": "ausgewählt", "title": "Einsätze", "type": "Typ", + "udf_select_placeholder": "{{field}} auswählen", "udf_suggestions": "Vorschläge", "units": "Einheiten", "users": "Benutzer", @@ -798,6 +801,11 @@ "call_selected": "Einsatz ausgewählt", "unit_selected": "Einheit ausgewählt", "unit_deselected": "Einheit abgewählt", + "unit_map_focus_crew_located": "{{located}} von {{total}} Besatzungsmitgliedern geortet", + "unit_map_focus_no_crew": "Keine Besatzung zugewiesen", + "unit_map_focus_no_unit_position": "Einheit meldet keine Position", + "unit_map_focus_no_positions": "Für diese Einheit und ihre Besatzung liegen keine Positionen vor", + "unit_map_focus_recenter": "Auf Einheit zentrieren", "personnel_selected": "Personal ausgewählt", "personnel_deselected": "Personal abgewählt", "loading_map": "Karte wird geladen...", @@ -1193,7 +1201,8 @@ "chat": "Chat", "assistant": "Assistent", "pending_calls": "Ausstehende Einsätze", - "awaiting_resources": "Warten auf Einsatzmittel" + "awaiting_resources": "Warten auf Einsatzmittel", + "archived_calls": "Archivierte Einsätze" }, "mfa": { "account": { @@ -1935,6 +1944,65 @@ "table_address": "Adresse", "table_scheduled": "Geplant für" }, + "archived_calls": { + "title": "Archivierte Einsätze", + "loading": "Archivierte Einsätze werden geladen...", + "no_archived_calls": "Keine archivierten Einsätze", + "no_archived_calls_description": "Für {{year}} gibt es keine abgeschlossenen Einsätze.", + "no_matches": "Keine passenden Einsätze", + "no_matches_description": "Keine archivierten Einsätze entsprechen Ihrer Suche und Ihren Filtern.", + "search": "Archivierte Einsätze durchsuchen...", + "year": "Jahr", + "filters": "Filter", + "clear_filters": "Filter zurücksetzen", + "logged_between": "Erfasst zwischen", + "from_date": "Von", + "to_date": "Bis", + "showing": "{{count}} von {{total}} Einsätzen", + "table_state": "Abgeschlossen als", + "table_logged": "Erfasst am", + "table_closed": "Abgeschlossen am", + "add": "Archivierten Einsatz hinzufügen", + "add_title": "Archivierten Einsatz hinzufügen", + "add_description": "Erfassen Sie einen Einsatz, der bereits stattgefunden hat. Er wird als abgeschlossen gespeichert und niemand wird benachrichtigt.", + "section_call": "Einsatz", + "section_closing": "Zeitverlauf & Abschluss", + "logged_on": "Erfasst am", + "closed_on": "Abgeschlossen am", + "closed_on_helper": "Leer lassen, um die aktuelle Zeit zu verwenden.", + "closing_notes": "Abschlussnotizen", + "closing_notes_placeholder": "Wie der Einsatz erledigt wurde", + "section_location": "Ort", + "address_helper": "Ohne Koordinaten wird die Adresse für die Karte ermittelt.", + "latitude": "Breitengrad", + "longitude": "Längengrad", + "section_contact": "Kontakt & Kennungen", + "reference_id": "Referenz-ID", + "external_id": "Externe ID", + "no_type": "Kein Typ", + "section_dispatched": "Alarmierte Kräfte", + "dispatched_hint": "Wird nur am Einsatz vermerkt. Niemand wird benachrichtigt.", + "select_dispatched": "Kräfte auswählen", + "dispatched_none": "Keine erfasst", + "dispatched_everyone": "Alle", + "dispatched_summary": "{{users}} Personen, {{groups}} Gruppen, {{roles}} Rollen, {{units}} Einheiten", + "section_numbering": "Einsatznummerierung", + "recalculate_numbers": "Einsatznummern neu berechnen", + "recalculate_numbers_hint": "Nummeriert die Einsätze des Jahres in der Reihenfolge ihrer Erfassung neu, damit dieser Einsatz seinen Platz in der Folge erhält.", + "save": "Archivierten Einsatz speichern", + "save_success": "Archivierter Einsatz gespeichert", + "save_error": "Der archivierte Einsatz konnte nicht gespeichert werden", + "name_required": "Name ist erforderlich", + "nature_required": "Art ist erforderlich", + "priority_required": "Wählen Sie eine Priorität", + "state_required": "Wählen Sie, wie der Einsatz abgeschlossen wurde", + "logged_on_required": "Geben Sie an, wann der Einsatz erfasst wurde", + "logged_on_future": "Der Einsatz kann nicht in der Zukunft erfasst werden", + "closed_on_invalid": "Geben Sie eine gültige Abschlusszeit ein", + "closed_on_before_logged": "Der Einsatz kann nicht vor seiner Erfassung abgeschlossen werden", + "closed_on_future": "Der Einsatz kann nicht in der Zukunft abgeschlossen werden", + "coordinates_invalid": "Geben Sie Breiten- und Längengrad in Dezimalgrad ein oder lassen Sie beide leer" + }, "pending_calls": { "title": "Ausstehende Einsätze", "loading": "Ausstehende Einsätze werden geladen...", diff --git a/src/translations/el.json b/src/translations/el.json index 1b49f518..eea2f57b 100644 --- a/src/translations/el.json +++ b/src/translations/el.json @@ -240,13 +240,8 @@ "contact_phone": "Τηλέφωνο Επαφής", "contact_phone_placeholder": "Εισαγάγετε το τηλέφωνο της επαφής", "coordinates": "Συντεταγμένες GPS", - "coordinates_found": "Οι συντεταγμένες βρέθηκαν και η διεύθυνση ενημερώθηκε", - "coordinates_geocoding_error": "Αποτυχία λήψης διεύθυνσης για τις συντεταγμένες, αλλά η τοποθεσία ορίστηκε στον χάρτη", - "coordinates_invalid_format": "Μη έγκυρη μορφή συντεταγμένων. Χρησιμοποιήστε τη μορφή: γεωγραφικό πλάτος, γεωγραφικό μήκος", - "coordinates_no_address": "Οι συντεταγμένες ορίστηκαν στον χάρτη, αλλά δεν βρέθηκε διεύθυνση", - "coordinates_out_of_range": "Οι συντεταγμένες είναι εκτός εύρους. Το πλάτος πρέπει να είναι -90 έως 90, το μήκος -180 έως 180", - "coordinates_placeholder": "Εισαγάγετε συντεταγμένες GPS (π.χ., 37.7749, -122.4194)", - "coordinates_required": "Εισαγάγετε συντεταγμένες για αναζήτηση", + "coordinates_incomplete": "Εισαγάγετε γεωγραφικό πλάτος και μήκος", + "coordinates_invalid": "Ελέγξτε το γεωγραφικό πλάτος και μήκος: απαιτούνται και τα δύο, σε δεκαδικές μοίρες", "create": "Δημιουργία", "create_error": "Σφάλμα κατά τη δημιουργία κλήσης", "create_new_call": "Δημιουργία Νέας Κλήσης", @@ -273,9 +268,16 @@ "indoor_location": "Εσωτερική Τοποθεσία", "invalid_priority": "Επιλέχθηκε μη έγκυρη προτεραιότητα. Επιλέξτε έγκυρη προτεραιότητα.", "invalid_type": "Επιλέχθηκε μη έγκυρος τύπος. Επιλέξτε έγκυρο τύπο κλήσης.", + "latitude": "Γεωγραφικό πλάτος", + "latitude_invalid": "Εισαγάγετε γεωγραφικό πλάτος σε δεκαδικές μοίρες, από -90 έως 90", + "latitude_placeholder": "π.χ. 39.122639", "loading": "Φόρτωση κλήσεων...", "loading_calls": "Φόρτωση κλήσεων...", "loading_destination_pois": "Φόρτωση σημείων ενδιαφέροντος προορισμού...", + "longitude": "Γεωγραφικό μήκος", + "longitude_invalid": "Εισαγάγετε γεωγραφικό μήκος σε δεκαδικές μοίρες, από -180 έως 180", + "longitude_placeholder": "π.χ. -119.758773", + "looking_up_address": "Αναζήτηση διεύθυνσης για αυτές τις συντεταγμένες...", "name": "Όνομα", "name_placeholder": "Εισαγάγετε το όνομα της κλήσης", "nature": "Φύση", @@ -323,6 +325,7 @@ "selected": "επιλεγμένα", "title": "Κλήσεις", "type": "Τύπος", + "udf_select_placeholder": "Επιλέξτε {{field}}", "udf_suggestions": "Προτάσεις", "units": "Μονάδες", "users": "Χρήστες", @@ -798,6 +801,11 @@ "call_selected": "Επιλέχθηκε Κλήση", "unit_selected": "Επιλέχθηκε Μονάδα", "unit_deselected": "Αποεπιλέχθηκε Μονάδα", + "unit_map_focus_crew_located": "Εντοπίστηκαν {{located}} από {{total}} μέλη πληρώματος", + "unit_map_focus_no_crew": "Δεν έχει ανατεθεί πλήρωμα", + "unit_map_focus_no_unit_position": "Η μονάδα δεν αναφέρει θέση", + "unit_map_focus_no_positions": "Δεν υπάρχουν θέσεις για αυτή τη μονάδα ή το πλήρωμά της", + "unit_map_focus_recenter": "Κεντράρισμα στη μονάδα", "personnel_selected": "Επιλέχθηκε Προσωπικό", "personnel_deselected": "Αποεπιλέχθηκε Προσωπικό", "loading_map": "Φόρτωση χάρτη...", @@ -1193,7 +1201,8 @@ "chat": "Συνομιλία", "assistant": "Βοηθός", "pending_calls": "Εκκρεμείς Κλήσεις", - "awaiting_resources": "Αναμονή πόρων" + "awaiting_resources": "Αναμονή πόρων", + "archived_calls": "Αρχειοθετημένες κλήσεις" }, "mfa": { "account": { @@ -1935,6 +1944,65 @@ "table_address": "Διεύθυνση", "table_scheduled": "Προγραμματισμένη Για" }, + "archived_calls": { + "title": "Αρχειοθετημένες κλήσεις", + "loading": "Φόρτωση αρχειοθετημένων κλήσεων...", + "no_archived_calls": "Δεν υπάρχουν αρχειοθετημένες κλήσεις", + "no_archived_calls_description": "Δεν υπάρχουν κλειστές κλήσεις για το {{year}}.", + "no_matches": "Δεν βρέθηκαν κλήσεις", + "no_matches_description": "Καμία αρχειοθετημένη κλήση δεν ταιριάζει με την αναζήτηση και τα φίλτρα σας.", + "search": "Αναζήτηση αρχειοθετημένων κλήσεων...", + "year": "Έτος", + "filters": "Φίλτρα", + "clear_filters": "Εκκαθάριση φίλτρων", + "logged_between": "Καταγράφηκε μεταξύ", + "from_date": "Από", + "to_date": "Έως", + "showing": "{{count}} από {{total}} κλήσεις", + "table_state": "Έκλεισε ως", + "table_logged": "Καταγράφηκε", + "table_closed": "Έκλεισε", + "add": "Προσθήκη αρχειοθετημένης κλήσης", + "add_title": "Προσθήκη αρχειοθετημένης κλήσης", + "add_description": "Καταχωρίστε μια κλήση που έχει ήδη γίνει. Αποθηκεύεται ως κλειστή και κανείς δεν ειδοποιείται.", + "section_call": "Κλήση", + "section_closing": "Χρονολόγιο και κλείσιμο", + "logged_on": "Καταγράφηκε", + "closed_on": "Έκλεισε", + "closed_on_helper": "Αφήστε κενό για να χρησιμοποιηθεί η τρέχουσα ώρα.", + "closing_notes": "Σημειώσεις κλεισίματος", + "closing_notes_placeholder": "Πώς επιλύθηκε η κλήση", + "section_location": "Τοποθεσία", + "address_helper": "Χωρίς συντεταγμένες, η διεύθυνση εντοπίζεται για τον χάρτη.", + "latitude": "Γεωγραφικό πλάτος", + "longitude": "Γεωγραφικό μήκος", + "section_contact": "Επαφή και αναγνωριστικά", + "reference_id": "ID αναφοράς", + "external_id": "Εξωτερικό ID", + "no_type": "Χωρίς τύπο", + "section_dispatched": "Διατεθειμένοι πόροι", + "dispatched_hint": "Καταγράφεται μόνο στην κλήση. Κανείς δεν ειδοποιείται.", + "select_dispatched": "Επιλογή πόρων", + "dispatched_none": "Δεν καταγράφηκε κανείς", + "dispatched_everyone": "Όλοι", + "dispatched_summary": "Προσωπικό: {{users}}, ομάδες: {{groups}}, ρόλοι: {{roles}}, μονάδες: {{units}}", + "section_numbering": "Αρίθμηση κλήσεων", + "recalculate_numbers": "Επαναϋπολογισμός αριθμών κλήσεων", + "recalculate_numbers_hint": "Επαναριθμεί τις κλήσεις του έτους με τη σειρά καταγραφής τους, ώστε αυτή η κλήση να πάρει τη θέση της στη σειρά.", + "save": "Αποθήκευση αρχειοθετημένης κλήσης", + "save_success": "Η αρχειοθετημένη κλήση αποθηκεύτηκε", + "save_error": "Αποτυχία αποθήκευσης της αρχειοθετημένης κλήσης", + "name_required": "Το όνομα είναι υποχρεωτικό", + "nature_required": "Η φύση είναι υποχρεωτική", + "priority_required": "Επιλέξτε προτεραιότητα", + "state_required": "Επιλέξτε πώς έκλεισε η κλήση", + "logged_on_required": "Ορίστε πότε καταγράφηκε η κλήση", + "logged_on_future": "Η κλήση δεν μπορεί να καταγραφεί στο μέλλον", + "closed_on_invalid": "Εισαγάγετε έγκυρη ώρα κλεισίματος", + "closed_on_before_logged": "Η κλήση δεν μπορεί να κλείσει πριν καταγραφεί", + "closed_on_future": "Η κλήση δεν μπορεί να κλείσει στο μέλλον", + "coordinates_invalid": "Εισαγάγετε γεωγραφικό πλάτος και μήκος σε δεκαδικές μοίρες ή αφήστε και τα δύο κενά" + }, "pending_calls": { "title": "Εκκρεμείς Κλήσεις", "loading": "Φόρτωση εκκρεμών κλήσεων...", diff --git a/src/translations/en.json b/src/translations/en.json index 84f9c858..79e37505 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -240,13 +240,8 @@ "contact_phone": "Contact Phone", "contact_phone_placeholder": "Enter the phone of the contact", "coordinates": "GPS Coordinates", - "coordinates_found": "Coordinates found and address updated", - "coordinates_geocoding_error": "Failed to get address for coordinates, but location set on map", - "coordinates_invalid_format": "Invalid coordinates format. Please use format: latitude, longitude", - "coordinates_no_address": "Coordinates set on map, but no address found", - "coordinates_out_of_range": "Coordinates out of range. Latitude must be -90 to 90, longitude -180 to 180", - "coordinates_placeholder": "Enter GPS coordinates (e.g., 37.7749, -122.4194)", - "coordinates_required": "Please enter coordinates to search", + "coordinates_incomplete": "Enter both latitude and longitude", + "coordinates_invalid": "Check the latitude and longitude: both are needed, in decimal degrees", "create": "Create", "create_error": "Error creating call", "create_new_call": "Create New Call", @@ -273,9 +268,16 @@ "indoor_location": "Indoor Location", "invalid_priority": "Invalid priority selected. Please select a valid priority.", "invalid_type": "Invalid type selected. Please select a valid call type.", + "latitude": "Latitude", + "latitude_invalid": "Enter a latitude in decimal degrees, between -90 and 90", + "latitude_placeholder": "e.g. 39.122639", "loading": "Loading calls...", "loading_calls": "Loading calls...", "loading_destination_pois": "Loading destination POIs...", + "longitude": "Longitude", + "longitude_invalid": "Enter a longitude in decimal degrees, between -180 and 180", + "longitude_placeholder": "e.g. -119.758773", + "looking_up_address": "Looking up the address for these coordinates...", "name": "Name", "name_placeholder": "Enter the name of the call", "nature": "Nature", @@ -323,6 +325,7 @@ "selected": "selected", "title": "Calls", "type": "Type", + "udf_select_placeholder": "Select {{field}}", "udf_suggestions": "Suggestions", "units": "Units", "users": "Users", @@ -798,6 +801,11 @@ "call_selected": "Call Selected", "unit_selected": "Unit Selected", "unit_deselected": "Unit Deselected", + "unit_map_focus_crew_located": "{{located}} of {{total}} crew located", + "unit_map_focus_no_crew": "No crew assigned", + "unit_map_focus_no_unit_position": "Unit not reporting a position", + "unit_map_focus_no_positions": "No positions reported for this unit or its crew", + "unit_map_focus_recenter": "Re-center on unit", "personnel_selected": "Personnel Selected", "personnel_deselected": "Personnel Deselected", "loading_map": "Loading map...", @@ -1193,7 +1201,8 @@ "chat": "Chat", "assistant": "Assistant", "pending_calls": "Pending Calls", - "awaiting_resources": "Awaiting Resources" + "awaiting_resources": "Awaiting Resources", + "archived_calls": "Archived Calls" }, "mfa": { "account": { @@ -1935,6 +1944,65 @@ "table_address": "Address", "table_scheduled": "Scheduled For" }, + "archived_calls": { + "title": "Archived Calls", + "loading": "Loading archived calls...", + "no_archived_calls": "No archived calls", + "no_archived_calls_description": "There are no closed calls for {{year}}.", + "no_matches": "No matching calls", + "no_matches_description": "No archived calls match your search and filters.", + "search": "Search archived calls...", + "year": "Year", + "filters": "Filters", + "clear_filters": "Clear filters", + "logged_between": "Logged between", + "from_date": "From", + "to_date": "To", + "showing": "{{count}} of {{total}} calls", + "table_state": "Closed As", + "table_logged": "Logged On", + "table_closed": "Closed On", + "add": "Add Archived Call", + "add_title": "Add Archived Call", + "add_description": "Enter a call that has already happened. It is saved as closed and nobody is notified.", + "section_call": "Call", + "section_closing": "Timeline & Closing", + "logged_on": "Logged On", + "closed_on": "Closed On", + "closed_on_helper": "Leave blank to use the current time.", + "closing_notes": "Closing Notes", + "closing_notes_placeholder": "How the call was resolved", + "section_location": "Location", + "address_helper": "Without coordinates, the address is located for the map.", + "latitude": "Latitude", + "longitude": "Longitude", + "section_contact": "Contact & Identifiers", + "reference_id": "Reference ID", + "external_id": "External ID", + "no_type": "No Type", + "section_dispatched": "Dispatched Resources", + "dispatched_hint": "Recorded on the call only. Nobody is notified.", + "select_dispatched": "Select Resources", + "dispatched_none": "None recorded", + "dispatched_everyone": "Everyone", + "dispatched_summary": "{{users}} personnel, {{groups}} groups, {{roles}} roles, {{units}} units", + "section_numbering": "Call Numbering", + "recalculate_numbers": "Recalculate call numbers", + "recalculate_numbers_hint": "Renumbers the year's calls in the order they were logged, so this call takes its place in the sequence.", + "save": "Save Archived Call", + "save_success": "Archived call saved", + "save_error": "Failed to save the archived call", + "name_required": "Name is required", + "nature_required": "Nature is required", + "priority_required": "Select a priority", + "state_required": "Select how the call was closed", + "logged_on_required": "Enter when the call was logged", + "logged_on_future": "The call cannot be logged in the future", + "closed_on_invalid": "Enter a valid close time", + "closed_on_before_logged": "The call cannot be closed before it was logged", + "closed_on_future": "The call cannot be closed in the future", + "coordinates_invalid": "Enter both latitude and longitude in decimal degrees, or leave both blank" + }, "pending_calls": { "title": "Pending Calls", "loading": "Loading pending calls...", diff --git a/src/translations/es.json b/src/translations/es.json index e4247bc6..72a6011b 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -240,13 +240,8 @@ "contact_phone": "Teléfono del contacto", "contact_phone_placeholder": "Introduce el teléfono del contacto", "coordinates": "Coordenadas GPS", - "coordinates_found": "Coordenadas encontradas y dirección actualizada", - "coordinates_geocoding_error": "Error al obtener la dirección para las coordenadas, pero ubicación establecida en el mapa", - "coordinates_invalid_format": "Formato de coordenadas inválido. Use el formato: latitud, longitud", - "coordinates_no_address": "Coordenadas establecidas en el mapa, pero no se encontró dirección", - "coordinates_out_of_range": "Coordenadas fuera de rango. La latitud debe estar entre -90 y 90, longitud entre -180 y 180", - "coordinates_placeholder": "Introduce coordenadas GPS (ej: 37.7749, -122.4194)", - "coordinates_required": "Por favor ingrese coordenadas para buscar", + "coordinates_incomplete": "Introduzca la latitud y la longitud", + "coordinates_invalid": "Revise la latitud y la longitud: se necesitan ambas, en grados decimales", "create": "Crear", "create_error": "Error al crear la llamada", "create_new_call": "Crear nueva llamada", @@ -273,9 +268,16 @@ "indoor_location": "Ubicación interior", "invalid_priority": "Prioridad inválida seleccionada. Por favor seleccione una prioridad válida.", "invalid_type": "Tipo inválido seleccionado. Por favor seleccione un tipo de llamada válido.", + "latitude": "Latitud", + "latitude_invalid": "Introduzca una latitud en grados decimales, entre -90 y 90", + "latitude_placeholder": "p. ej. 39.122639", "loading": "Cargando llamadas...", "loading_calls": "Cargando llamadas...", "loading_destination_pois": "Cargando PDI de destino...", + "longitude": "Longitud", + "longitude_invalid": "Introduzca una longitud en grados decimales, entre -180 y 180", + "longitude_placeholder": "p. ej. -119.758773", + "looking_up_address": "Buscando la dirección de estas coordenadas...", "name": "Nombre", "name_placeholder": "Introduce el nombre de la llamada", "nature": "Naturaleza", @@ -323,6 +325,7 @@ "selected": "seleccionados", "title": "Llamadas", "type": "Tipo", + "udf_select_placeholder": "Seleccione {{field}}", "udf_suggestions": "Sugerencias", "units": "Unidades", "users": "Usuarios", @@ -798,6 +801,11 @@ "call_selected": "Llamada Seleccionada", "unit_selected": "Unidad Seleccionada", "unit_deselected": "Unidad Deseleccionada", + "unit_map_focus_crew_located": "{{located}} de {{total}} integrantes ubicados", + "unit_map_focus_no_crew": "Sin dotación asignada", + "unit_map_focus_no_unit_position": "La unidad no informa su posición", + "unit_map_focus_no_positions": "No hay posiciones para esta unidad ni su dotación", + "unit_map_focus_recenter": "Centrar en la unidad", "personnel_selected": "Personal Seleccionado", "personnel_deselected": "Personal Deseleccionado", "loading_map": "Cargando mapa...", @@ -1193,7 +1201,8 @@ "chat": "Chat", "assistant": "Asistente", "pending_calls": "Llamadas pendientes", - "awaiting_resources": "En espera de recursos" + "awaiting_resources": "En espera de recursos", + "archived_calls": "Llamadas archivadas" }, "mfa": { "account": { @@ -1935,6 +1944,65 @@ "table_address": "Dirección", "table_scheduled": "Programada para" }, + "archived_calls": { + "title": "Llamadas archivadas", + "loading": "Cargando llamadas archivadas...", + "no_archived_calls": "No hay llamadas archivadas", + "no_archived_calls_description": "No hay llamadas cerradas en {{year}}.", + "no_matches": "No hay llamadas coincidentes", + "no_matches_description": "Ninguna llamada archivada coincide con su búsqueda y filtros.", + "search": "Buscar llamadas archivadas...", + "year": "Año", + "filters": "Filtros", + "clear_filters": "Borrar filtros", + "logged_between": "Registrada entre", + "from_date": "Desde", + "to_date": "Hasta", + "showing": "{{count}} de {{total}} llamadas", + "table_state": "Cerrada como", + "table_logged": "Registrada el", + "table_closed": "Cerrada el", + "add": "Agregar llamada archivada", + "add_title": "Agregar llamada archivada", + "add_description": "Ingrese una llamada que ya ocurrió. Se guarda como cerrada y no se notifica a nadie.", + "section_call": "Llamada", + "section_closing": "Cronología y cierre", + "logged_on": "Registrada el", + "closed_on": "Cerrada el", + "closed_on_helper": "Déjelo en blanco para usar la hora actual.", + "closing_notes": "Notas de cierre", + "closing_notes_placeholder": "Cómo se resolvió la llamada", + "section_location": "Ubicación", + "address_helper": "Sin coordenadas, la dirección se ubica para el mapa.", + "latitude": "Latitud", + "longitude": "Longitud", + "section_contact": "Contacto e identificadores", + "reference_id": "ID de referencia", + "external_id": "ID externo", + "no_type": "Sin tipo", + "section_dispatched": "Recursos despachados", + "dispatched_hint": "Solo se registra en la llamada. No se notifica a nadie.", + "select_dispatched": "Seleccionar recursos", + "dispatched_none": "Ninguno registrado", + "dispatched_everyone": "Todos", + "dispatched_summary": "{{users}} personas, {{groups}} grupos, {{roles}} roles, {{units}} unidades", + "section_numbering": "Numeración de llamadas", + "recalculate_numbers": "Recalcular números de llamada", + "recalculate_numbers_hint": "Vuelve a numerar las llamadas del año en el orden en que se registraron, para que esta llamada ocupe su lugar en la secuencia.", + "save": "Guardar llamada archivada", + "save_success": "Llamada archivada guardada", + "save_error": "No se pudo guardar la llamada archivada", + "name_required": "El nombre es obligatorio", + "nature_required": "La naturaleza es obligatoria", + "priority_required": "Seleccione una prioridad", + "state_required": "Seleccione cómo se cerró la llamada", + "logged_on_required": "Indique cuándo se registró la llamada", + "logged_on_future": "La llamada no puede registrarse en el futuro", + "closed_on_invalid": "Ingrese una hora de cierre válida", + "closed_on_before_logged": "La llamada no puede cerrarse antes de registrarse", + "closed_on_future": "La llamada no puede cerrarse en el futuro", + "coordinates_invalid": "Ingrese latitud y longitud en grados decimales, o deje ambas en blanco" + }, "pending_calls": { "title": "Llamadas pendientes", "loading": "Cargando llamadas pendientes...", diff --git a/src/translations/fr.json b/src/translations/fr.json index 3e4514aa..e80de3ef 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -225,6 +225,10 @@ "destination": "Destination", "destination_poi": "POI de destination", "loading_destination_pois": "Chargement des POI de destination...", + "longitude": "Longitude", + "longitude_invalid": "Saisissez une longitude en degrés décimaux, entre -180 et 180", + "longitude_placeholder": "ex. -119.758773", + "looking_up_address": "Recherche de l'adresse pour ces coordonnées...", "no_destination": "Aucune destination", "no_destination_pois_available": "Aucun POI de destination disponible", "select_destination_poi": "Sélectionner un POI de destination", @@ -246,13 +250,8 @@ "contact_phone": "Téléphone du contact", "contact_phone_placeholder": "Saisissez le téléphone du contact", "coordinates": "Coordonnées GPS", - "coordinates_found": "Coordonnées trouvées et adresse mise à jour", - "coordinates_geocoding_error": "Échec de l'obtention de l'adresse pour les coordonnées, mais localisation définie sur la carte", - "coordinates_invalid_format": "Format de coordonnées invalide. Veuillez utiliser le format : latitude, longitude", - "coordinates_no_address": "Coordonnées définies sur la carte, mais aucune adresse trouvée", - "coordinates_out_of_range": "Coordonnées hors limites. La latitude doit être entre -90 et 90, la longitude entre -180 et 180", - "coordinates_placeholder": "Saisissez les coordonnées GPS (ex. : 37.7749, -122.4194)", - "coordinates_required": "Veuillez saisir des coordonnées pour la recherche", + "coordinates_incomplete": "Saisissez la latitude et la longitude", + "coordinates_invalid": "Vérifiez la latitude et la longitude : les deux sont requises, en degrés décimaux", "create": "Créer", "create_error": "Erreur lors de la création de l'appel", "create_new_call": "Créer un nouvel appel", @@ -277,6 +276,9 @@ "indoor_location": "Emplacement intérieur", "invalid_priority": "Priorité invalide sélectionnée. Veuillez sélectionner une priorité valide.", "invalid_type": "Type invalide sélectionné. Veuillez sélectionner un type d'appel valide.", + "latitude": "Latitude", + "latitude_invalid": "Saisissez une latitude en degrés décimaux, entre -90 et 90", + "latitude_placeholder": "ex. 39.122639", "loading": "Chargement des appels...", "loading_calls": "Chargement des appels...", "name": "Nom", @@ -323,6 +325,7 @@ "selected": "sélectionné", "title": "Appels", "type": "Type", + "udf_select_placeholder": "Sélectionnez {{field}}", "udf_suggestions": "Suggestions", "units": "Unités", "users": "Utilisateurs", @@ -798,6 +801,11 @@ "call_selected": "Appel sélectionné", "unit_selected": "Unité sélectionnée", "unit_deselected": "Unité désélectionnée", + "unit_map_focus_crew_located": "{{located}} sur {{total}} membres d'équipage localisés", + "unit_map_focus_no_crew": "Aucun équipage affecté", + "unit_map_focus_no_unit_position": "L'unité ne transmet pas sa position", + "unit_map_focus_no_positions": "Aucune position pour cette unité ni son équipage", + "unit_map_focus_recenter": "Recentrer sur l'unité", "personnel_selected": "Personnel sélectionné", "personnel_deselected": "Personnel désélectionné", "loading_map": "Chargement de la carte...", @@ -1193,7 +1201,8 @@ "chat": "Chat", "assistant": "Assistant", "pending_calls": "Interventions en attente", - "awaiting_resources": "En attente de moyens" + "awaiting_resources": "En attente de moyens", + "archived_calls": "Appels archivés" }, "mfa": { "account": { @@ -1935,6 +1944,65 @@ "table_address": "Adresse", "table_scheduled": "Planifiée pour" }, + "archived_calls": { + "title": "Appels archivés", + "loading": "Chargement des appels archivés...", + "no_archived_calls": "Aucun appel archivé", + "no_archived_calls_description": "Il n'y a aucun appel clôturé pour {{year}}.", + "no_matches": "Aucun appel correspondant", + "no_matches_description": "Aucun appel archivé ne correspond à votre recherche et à vos filtres.", + "search": "Rechercher des appels archivés...", + "year": "Année", + "filters": "Filtres", + "clear_filters": "Effacer les filtres", + "logged_between": "Enregistré entre", + "from_date": "Du", + "to_date": "Au", + "showing": "{{count}} sur {{total}} appels", + "table_state": "Clôturé comme", + "table_logged": "Enregistré le", + "table_closed": "Clôturé le", + "add": "Ajouter un appel archivé", + "add_title": "Ajouter un appel archivé", + "add_description": "Saisissez un appel qui a déjà eu lieu. Il est enregistré comme clôturé et personne n'est notifié.", + "section_call": "Appel", + "section_closing": "Chronologie et clôture", + "logged_on": "Enregistré le", + "closed_on": "Clôturé le", + "closed_on_helper": "Laissez vide pour utiliser l'heure actuelle.", + "closing_notes": "Notes de clôture", + "closing_notes_placeholder": "Comment l'appel a été résolu", + "section_location": "Emplacement", + "address_helper": "Sans coordonnées, l'adresse est localisée pour la carte.", + "latitude": "Latitude", + "longitude": "Longitude", + "section_contact": "Contact et identifiants", + "reference_id": "ID de référence", + "external_id": "ID externe", + "no_type": "Aucun type", + "section_dispatched": "Ressources engagées", + "dispatched_hint": "Enregistré sur l'appel uniquement. Personne n'est notifié.", + "select_dispatched": "Sélectionner les ressources", + "dispatched_none": "Aucune enregistrée", + "dispatched_everyone": "Tout le monde", + "dispatched_summary": "{{users}} personnes, {{groups}} groupes, {{roles}} rôles, {{units}} unités", + "section_numbering": "Numérotation des appels", + "recalculate_numbers": "Recalculer les numéros d'appel", + "recalculate_numbers_hint": "Renumérote les appels de l'année dans l'ordre de leur enregistrement, afin que cet appel prenne sa place dans la séquence.", + "save": "Enregistrer l'appel archivé", + "save_success": "Appel archivé enregistré", + "save_error": "Échec de l'enregistrement de l'appel archivé", + "name_required": "Le nom est requis", + "nature_required": "La nature est requise", + "priority_required": "Sélectionnez une priorité", + "state_required": "Sélectionnez comment l'appel a été clôturé", + "logged_on_required": "Indiquez quand l'appel a été enregistré", + "logged_on_future": "L'appel ne peut pas être enregistré dans le futur", + "closed_on_invalid": "Saisissez une heure de clôture valide", + "closed_on_before_logged": "L'appel ne peut pas être clôturé avant son enregistrement", + "closed_on_future": "L'appel ne peut pas être clôturé dans le futur", + "coordinates_invalid": "Saisissez la latitude et la longitude en degrés décimaux, ou laissez les deux vides" + }, "pending_calls": { "title": "Interventions en attente", "loading": "Chargement des interventions en attente...", diff --git a/src/translations/it.json b/src/translations/it.json index 9a4bc81b..f85ade53 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -225,6 +225,10 @@ "destination": "Destinazione", "destination_poi": "POI di destinazione", "loading_destination_pois": "Caricamento dei POI di destinazione...", + "longitude": "Longitudine", + "longitude_invalid": "Inserisci una longitudine in gradi decimali, tra -180 e 180", + "longitude_placeholder": "es. -119.758773", + "looking_up_address": "Ricerca dell'indirizzo per queste coordinate...", "no_destination": "Nessuna destinazione", "no_destination_pois_available": "Nessun POI di destinazione disponibile", "select_destination_poi": "Seleziona POI di destinazione", @@ -246,13 +250,8 @@ "contact_phone": "Telefono contatto", "contact_phone_placeholder": "Inserisci il telefono del contatto", "coordinates": "Coordinate GPS", - "coordinates_found": "Coordinate trovate e indirizzo aggiornato", - "coordinates_geocoding_error": "Impossibile ottenere l'indirizzo per le coordinate, ma la posizione è stata impostata sulla mappa", - "coordinates_invalid_format": "Formato coordinate non valido. Usa il formato: latitudine, longitudine", - "coordinates_no_address": "Coordinate impostate sulla mappa, ma nessun indirizzo trovato", - "coordinates_out_of_range": "Coordinate fuori intervallo. La latitudine deve essere tra -90 e 90, la longitudine tra -180 e 180", - "coordinates_placeholder": "Inserisci le coordinate GPS (es. 37.7749, -122.4194)", - "coordinates_required": "Inserisci le coordinate da cercare", + "coordinates_incomplete": "Inserisci sia la latitudine sia la longitudine", + "coordinates_invalid": "Controlla latitudine e longitudine: servono entrambe, in gradi decimali", "create": "Crea", "create_error": "Errore nella creazione dell'intervento", "create_new_call": "Crea nuovo intervento", @@ -277,6 +276,9 @@ "indoor_location": "Posizione interna", "invalid_priority": "Priorità selezionata non valida. Seleziona una priorità valida.", "invalid_type": "Tipo selezionato non valido. Seleziona un tipo di intervento valido.", + "latitude": "Latitudine", + "latitude_invalid": "Inserisci una latitudine in gradi decimali, tra -90 e 90", + "latitude_placeholder": "es. 39.122639", "loading": "Caricamento interventi...", "loading_calls": "Caricamento interventi...", "name": "Nome", @@ -323,6 +325,7 @@ "selected": "selezionato", "title": "Interventi", "type": "Tipo", + "udf_select_placeholder": "Seleziona {{field}}", "udf_suggestions": "Suggerimenti", "units": "Unità", "users": "Utenti", @@ -798,6 +801,11 @@ "call_selected": "Intervento selezionato", "unit_selected": "Unità selezionata", "unit_deselected": "Unità deselezionata", + "unit_map_focus_crew_located": "{{located}} di {{total}} membri dell'equipaggio localizzati", + "unit_map_focus_no_crew": "Nessun equipaggio assegnato", + "unit_map_focus_no_unit_position": "L'unità non trasmette la posizione", + "unit_map_focus_no_positions": "Nessuna posizione per questa unità o il suo equipaggio", + "unit_map_focus_recenter": "Centra sull'unità", "personnel_selected": "Personale selezionato", "personnel_deselected": "Personale deselezionato", "loading_map": "Caricamento mappa...", @@ -1193,7 +1201,8 @@ "chat": "Chat", "assistant": "Assistente", "pending_calls": "Chiamate in attesa", - "awaiting_resources": "In attesa di risorse" + "awaiting_resources": "In attesa di risorse", + "archived_calls": "Chiamate archiviate" }, "mfa": { "account": { @@ -1935,6 +1944,65 @@ "table_address": "Indirizzo", "table_scheduled": "Programmata per" }, + "archived_calls": { + "title": "Chiamate archiviate", + "loading": "Caricamento chiamate archiviate...", + "no_archived_calls": "Nessuna chiamata archiviata", + "no_archived_calls_description": "Non ci sono chiamate chiuse per il {{year}}.", + "no_matches": "Nessuna chiamata corrispondente", + "no_matches_description": "Nessuna chiamata archiviata corrisponde alla ricerca e ai filtri.", + "search": "Cerca chiamate archiviate...", + "year": "Anno", + "filters": "Filtri", + "clear_filters": "Cancella filtri", + "logged_between": "Registrata tra", + "from_date": "Dal", + "to_date": "Al", + "showing": "{{count}} di {{total}} chiamate", + "table_state": "Chiusa come", + "table_logged": "Registrata il", + "table_closed": "Chiusa il", + "add": "Aggiungi chiamata archiviata", + "add_title": "Aggiungi chiamata archiviata", + "add_description": "Inserisci una chiamata già avvenuta. Viene salvata come chiusa e nessuno viene avvisato.", + "section_call": "Chiamata", + "section_closing": "Cronologia e chiusura", + "logged_on": "Registrata il", + "closed_on": "Chiusa il", + "closed_on_helper": "Lascia vuoto per usare l'ora attuale.", + "closing_notes": "Note di chiusura", + "closing_notes_placeholder": "Come è stata risolta la chiamata", + "section_location": "Posizione", + "address_helper": "Senza coordinate, l'indirizzo viene localizzato per la mappa.", + "latitude": "Latitudine", + "longitude": "Longitudine", + "section_contact": "Contatto e identificativi", + "reference_id": "ID di riferimento", + "external_id": "ID esterno", + "no_type": "Nessun tipo", + "section_dispatched": "Risorse inviate", + "dispatched_hint": "Registrato solo sulla chiamata. Nessuno viene avvisato.", + "select_dispatched": "Seleziona risorse", + "dispatched_none": "Nessuna registrata", + "dispatched_everyone": "Tutti", + "dispatched_summary": "{{users}} persone, {{groups}} gruppi, {{roles}} ruoli, {{units}} unità", + "section_numbering": "Numerazione chiamate", + "recalculate_numbers": "Ricalcola i numeri di chiamata", + "recalculate_numbers_hint": "Rinumera le chiamate dell'anno nell'ordine di registrazione, così questa chiamata prende il suo posto nella sequenza.", + "save": "Salva chiamata archiviata", + "save_success": "Chiamata archiviata salvata", + "save_error": "Impossibile salvare la chiamata archiviata", + "name_required": "Il nome è obbligatorio", + "nature_required": "La natura è obbligatoria", + "priority_required": "Seleziona una priorità", + "state_required": "Seleziona come è stata chiusa la chiamata", + "logged_on_required": "Indica quando è stata registrata la chiamata", + "logged_on_future": "La chiamata non può essere registrata nel futuro", + "closed_on_invalid": "Inserisci un'ora di chiusura valida", + "closed_on_before_logged": "La chiamata non può essere chiusa prima della registrazione", + "closed_on_future": "La chiamata non può essere chiusa nel futuro", + "coordinates_invalid": "Inserisci latitudine e longitudine in gradi decimali, oppure lascia vuoti entrambi" + }, "pending_calls": { "title": "Chiamate in attesa", "loading": "Caricamento delle chiamate in attesa...", diff --git a/src/translations/pl.json b/src/translations/pl.json index 4c8fb1e6..17c688a2 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -225,6 +225,10 @@ "destination": "Miejsce docelowe", "destination_poi": "Docelowy POI", "loading_destination_pois": "Ładowanie docelowych POI...", + "longitude": "Długość geograficzna", + "longitude_invalid": "Podaj długość geograficzną w stopniach dziesiętnych, od -180 do 180", + "longitude_placeholder": "np. -119.758773", + "looking_up_address": "Wyszukiwanie adresu dla tych współrzędnych...", "no_destination": "Brak miejsca docelowego", "no_destination_pois_available": "Brak dostępnych docelowych POI", "select_destination_poi": "Wybierz docelowy POI", @@ -246,13 +250,8 @@ "contact_phone": "Telefon kontaktowy", "contact_phone_placeholder": "Wprowadź numer telefonu kontaktu", "coordinates": "Współrzędne GPS", - "coordinates_found": "Znaleziono współrzędne i zaktualizowano adres", - "coordinates_geocoding_error": "Nie udało się uzyskać adresu dla współrzędnych, ale lokalizacja została ustawiona na mapie", - "coordinates_invalid_format": "Nieprawidłowy format współrzędnych. Użyj formatu: szerokość, długość", - "coordinates_no_address": "Współrzędne ustawione na mapie, ale nie znaleziono adresu", - "coordinates_out_of_range": "Współrzędne poza zakresem. Szerokość geograficzna musi wynosić od -90 do 90, długość od -180 do 180", - "coordinates_placeholder": "Wprowadź współrzędne GPS (np. 37.7749, -122.4194)", - "coordinates_required": "Proszę podać współrzędne do wyszukania", + "coordinates_incomplete": "Podaj szerokość i długość geograficzną", + "coordinates_invalid": "Sprawdź szerokość i długość geograficzną: obie są wymagane, w stopniach dziesiętnych", "create": "Utwórz", "create_error": "Błąd tworzenia zgłoszenia", "create_new_call": "Utwórz nowe zgłoszenie", @@ -277,6 +276,9 @@ "indoor_location": "Lokalizacja w budynku", "invalid_priority": "Wybrano nieprawidłowy priorytet. Proszę wybrać prawidłowy priorytet.", "invalid_type": "Wybrano nieprawidłowy typ. Proszę wybrać prawidłowy typ zgłoszenia.", + "latitude": "Szerokość geograficzna", + "latitude_invalid": "Podaj szerokość geograficzną w stopniach dziesiętnych, od -90 do 90", + "latitude_placeholder": "np. 39.122639", "loading": "Ładowanie zgłoszeń...", "loading_calls": "Ładowanie zgłoszeń...", "name": "Nazwa", @@ -323,6 +325,7 @@ "selected": "wybrano", "title": "Zgłoszenia", "type": "Typ", + "udf_select_placeholder": "Wybierz {{field}}", "udf_suggestions": "Sugestie", "units": "Jednostki", "users": "Użytkownicy", @@ -798,6 +801,11 @@ "call_selected": "Zgłoszenie wybrane", "unit_selected": "Jednostka wybrana", "unit_deselected": "Jednostka odznaczona", + "unit_map_focus_crew_located": "Zlokalizowano {{located}} z {{total}} członków załogi", + "unit_map_focus_no_crew": "Brak przypisanej załogi", + "unit_map_focus_no_unit_position": "Jednostka nie zgłasza pozycji", + "unit_map_focus_no_positions": "Brak pozycji tej jednostki i jej załogi", + "unit_map_focus_recenter": "Wyśrodkuj na jednostce", "personnel_selected": "Personel wybrany", "personnel_deselected": "Personel odznaczony", "loading_map": "Ładowanie mapy...", @@ -1193,7 +1201,8 @@ "chat": "Czat", "assistant": "Asystent", "pending_calls": "Oczekujące zgłoszenia", - "awaiting_resources": "Oczekujące na zasoby" + "awaiting_resources": "Oczekujące na zasoby", + "archived_calls": "Zarchiwizowane zgłoszenia" }, "mfa": { "account": { @@ -1935,6 +1944,65 @@ "table_address": "Adres", "table_scheduled": "Zaplanowano na" }, + "archived_calls": { + "title": "Zarchiwizowane zgłoszenia", + "loading": "Ładowanie zarchiwizowanych zgłoszeń...", + "no_archived_calls": "Brak zarchiwizowanych zgłoszeń", + "no_archived_calls_description": "Brak zamkniętych zgłoszeń w roku {{year}}.", + "no_matches": "Brak pasujących zgłoszeń", + "no_matches_description": "Żadne zarchiwizowane zgłoszenie nie pasuje do wyszukiwania i filtrów.", + "search": "Szukaj zarchiwizowanych zgłoszeń...", + "year": "Rok", + "filters": "Filtry", + "clear_filters": "Wyczyść filtry", + "logged_between": "Zarejestrowane między", + "from_date": "Od", + "to_date": "Do", + "showing": "{{count}} z {{total}} zgłoszeń", + "table_state": "Zamknięte jako", + "table_logged": "Zarejestrowano", + "table_closed": "Zamknięto", + "add": "Dodaj zarchiwizowane zgłoszenie", + "add_title": "Dodaj zarchiwizowane zgłoszenie", + "add_description": "Wprowadź zgłoszenie, które już miało miejsce. Zostanie zapisane jako zamknięte i nikt nie zostanie powiadomiony.", + "section_call": "Zgłoszenie", + "section_closing": "Przebieg i zamknięcie", + "logged_on": "Zarejestrowano", + "closed_on": "Zamknięto", + "closed_on_helper": "Pozostaw puste, aby użyć bieżącego czasu.", + "closing_notes": "Notatki zamknięcia", + "closing_notes_placeholder": "Jak rozwiązano zgłoszenie", + "section_location": "Lokalizacja", + "address_helper": "Bez współrzędnych adres zostanie zlokalizowany na mapie.", + "latitude": "Szerokość geograficzna", + "longitude": "Długość geograficzna", + "section_contact": "Kontakt i identyfikatory", + "reference_id": "ID referencyjne", + "external_id": "ID zewnętrzne", + "no_type": "Brak typu", + "section_dispatched": "Zadysponowane zasoby", + "dispatched_hint": "Zapisywane tylko w zgłoszeniu. Nikt nie jest powiadamiany.", + "select_dispatched": "Wybierz zasoby", + "dispatched_none": "Brak zapisanych", + "dispatched_everyone": "Wszyscy", + "dispatched_summary": "Osoby: {{users}}, grupy: {{groups}}, role: {{roles}}, jednostki: {{units}}", + "section_numbering": "Numeracja zgłoszeń", + "recalculate_numbers": "Przelicz numery zgłoszeń", + "recalculate_numbers_hint": "Ponownie numeruje zgłoszenia z danego roku w kolejności rejestracji, aby to zgłoszenie zajęło swoje miejsce w sekwencji.", + "save": "Zapisz zarchiwizowane zgłoszenie", + "save_success": "Zarchiwizowane zgłoszenie zapisane", + "save_error": "Nie udało się zapisać zarchiwizowanego zgłoszenia", + "name_required": "Nazwa jest wymagana", + "nature_required": "Charakter jest wymagany", + "priority_required": "Wybierz priorytet", + "state_required": "Wybierz, jak zamknięto zgłoszenie", + "logged_on_required": "Podaj, kiedy zarejestrowano zgłoszenie", + "logged_on_future": "Zgłoszenie nie może być zarejestrowane w przyszłości", + "closed_on_invalid": "Podaj prawidłowy czas zamknięcia", + "closed_on_before_logged": "Zgłoszenie nie może zostać zamknięte przed rejestracją", + "closed_on_future": "Zgłoszenie nie może zostać zamknięte w przyszłości", + "coordinates_invalid": "Podaj szerokość i długość geograficzną w stopniach dziesiętnych lub pozostaw oba pola puste" + }, "pending_calls": { "title": "Oczekujące zgłoszenia", "loading": "Ładowanie oczekujących zgłoszeń...", diff --git a/src/translations/sv.json b/src/translations/sv.json index 1c1615bf..c5b16ff3 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -225,6 +225,10 @@ "destination": "Destination", "destination_poi": "Destinations-POI", "loading_destination_pois": "Läser in destinations-POI:er...", + "longitude": "Longitud", + "longitude_invalid": "Ange en longitud i decimalgrader, mellan -180 och 180", + "longitude_placeholder": "t.ex. -119.758773", + "looking_up_address": "Söker adress för koordinaterna...", "no_destination": "Ingen destination", "no_destination_pois_available": "Inga destinations-POI:er tillgängliga", "select_destination_poi": "Välj destinations-POI", @@ -246,13 +250,8 @@ "contact_phone": "Kontakttelefon", "contact_phone_placeholder": "Ange kontaktens telefonnummer", "coordinates": "GPS-koordinater", - "coordinates_found": "Koordinater hittade och adress uppdaterad", - "coordinates_geocoding_error": "Kunde inte hämta adress för koordinater, men plats markerad på kartan", - "coordinates_invalid_format": "Ogiltigt koordinatformat. Använd formatet: latitud, longitud", - "coordinates_no_address": "Koordinater markerade på kartan, men ingen adress hittades", - "coordinates_out_of_range": "Koordinater utanför giltigt intervall. Latitud måste vara -90 till 90, longitud -180 till 180", - "coordinates_placeholder": "Ange GPS-koordinater (t.ex. 37.7749, -122.4194)", - "coordinates_required": "Vänligen ange koordinater att söka efter", + "coordinates_incomplete": "Ange både latitud och longitud", + "coordinates_invalid": "Kontrollera latitud och longitud: båda behövs, i decimalgrader", "create": "Skapa", "create_error": "Fel vid skapande av ärende", "create_new_call": "Skapa nytt ärende", @@ -277,6 +276,9 @@ "indoor_location": "Inomhusplats", "invalid_priority": "Ogiltig prioritet vald. Vänligen välj en giltig prioritet.", "invalid_type": "Ogiltig typ vald. Vänligen välj en giltig ärendetyp.", + "latitude": "Latitud", + "latitude_invalid": "Ange en latitud i decimalgrader, mellan -90 och 90", + "latitude_placeholder": "t.ex. 39.122639", "loading": "Laddar ärenden...", "loading_calls": "Laddar ärenden...", "name": "Namn", @@ -323,6 +325,7 @@ "selected": "vald", "title": "Ärenden", "type": "Typ", + "udf_select_placeholder": "Välj {{field}}", "udf_suggestions": "Förslag", "units": "Enheter", "users": "Användare", @@ -798,6 +801,11 @@ "call_selected": "Ärende valt", "unit_selected": "Enhet vald", "unit_deselected": "Enhet avmarkerad", + "unit_map_focus_crew_located": "{{located}} av {{total}} i besättningen positionerade", + "unit_map_focus_no_crew": "Ingen besättning tilldelad", + "unit_map_focus_no_unit_position": "Enheten rapporterar ingen position", + "unit_map_focus_no_positions": "Inga positioner för enheten eller dess besättning", + "unit_map_focus_recenter": "Centrera på enheten", "personnel_selected": "Personal vald", "personnel_deselected": "Personal avmarkerad", "loading_map": "Laddar karta...", @@ -1193,7 +1201,8 @@ "chat": "Chatt", "assistant": "Assistent", "pending_calls": "Väntande larm", - "awaiting_resources": "Väntar på resurser" + "awaiting_resources": "Väntar på resurser", + "archived_calls": "Arkiverade larm" }, "mfa": { "account": { @@ -1935,6 +1944,65 @@ "table_address": "Adress", "table_scheduled": "Schemalagt till" }, + "archived_calls": { + "title": "Arkiverade larm", + "loading": "Laddar arkiverade larm...", + "no_archived_calls": "Inga arkiverade larm", + "no_archived_calls_description": "Det finns inga avslutade larm för {{year}}.", + "no_matches": "Inga matchande larm", + "no_matches_description": "Inga arkiverade larm matchar din sökning och dina filter.", + "search": "Sök arkiverade larm...", + "year": "År", + "filters": "Filter", + "clear_filters": "Rensa filter", + "logged_between": "Registrerat mellan", + "from_date": "Från", + "to_date": "Till", + "showing": "{{count}} av {{total}} larm", + "table_state": "Avslutat som", + "table_logged": "Registrerat", + "table_closed": "Avslutat", + "add": "Lägg till arkiverat larm", + "add_title": "Lägg till arkiverat larm", + "add_description": "Ange ett larm som redan har inträffat. Det sparas som avslutat och ingen aviseras.", + "section_call": "Larm", + "section_closing": "Tidslinje och avslut", + "logged_on": "Registrerat", + "closed_on": "Avslutat", + "closed_on_helper": "Lämna tomt för att använda aktuell tid.", + "closing_notes": "Avslutningsanteckningar", + "closing_notes_placeholder": "Hur larmet löstes", + "section_location": "Plats", + "address_helper": "Utan koordinater lokaliseras adressen för kartan.", + "latitude": "Latitud", + "longitude": "Longitud", + "section_contact": "Kontakt och identifierare", + "reference_id": "Referens-ID", + "external_id": "Externt ID", + "no_type": "Ingen typ", + "section_dispatched": "Larmade resurser", + "dispatched_hint": "Registreras endast på larmet. Ingen aviseras.", + "select_dispatched": "Välj resurser", + "dispatched_none": "Inga registrerade", + "dispatched_everyone": "Alla", + "dispatched_summary": "{{users}} personer, {{groups}} grupper, {{roles}} roller, {{units}} enheter", + "section_numbering": "Larmnumrering", + "recalculate_numbers": "Räkna om larmnummer", + "recalculate_numbers_hint": "Numrerar om årets larm i den ordning de registrerades, så att detta larm får sin plats i sekvensen.", + "save": "Spara arkiverat larm", + "save_success": "Arkiverat larm sparat", + "save_error": "Det gick inte att spara det arkiverade larmet", + "name_required": "Namn krävs", + "nature_required": "Karaktär krävs", + "priority_required": "Välj en prioritet", + "state_required": "Välj hur larmet avslutades", + "logged_on_required": "Ange när larmet registrerades", + "logged_on_future": "Larmet kan inte registreras i framtiden", + "closed_on_invalid": "Ange en giltig avslutningstid", + "closed_on_before_logged": "Larmet kan inte avslutas innan det registrerades", + "closed_on_future": "Larmet kan inte avslutas i framtiden", + "coordinates_invalid": "Ange både latitud och longitud i decimalgrader, eller lämna båda tomma" + }, "pending_calls": { "title": "Väntande larm", "loading": "Läser in väntande larm...", diff --git a/src/translations/uk.json b/src/translations/uk.json index 57152729..8adb710e 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -225,6 +225,10 @@ "destination": "Місце призначення", "destination_poi": "POI призначення", "loading_destination_pois": "Завантаження POI призначення...", + "longitude": "Довгота", + "longitude_invalid": "Введіть довготу в десяткових градусах, від -180 до 180", + "longitude_placeholder": "напр. -119.758773", + "looking_up_address": "Пошук адреси для цих координат...", "no_destination": "Без місця призначення", "no_destination_pois_available": "Немає доступних POI призначення", "select_destination_poi": "Виберіть POI призначення", @@ -246,13 +250,8 @@ "contact_phone": "Телефон контактної особи", "contact_phone_placeholder": "Введіть телефон контактної особи", "coordinates": "GPS-координати", - "coordinates_found": "Координати знайдено та адресу оновлено", - "coordinates_geocoding_error": "Не вдалося отримати адресу за координатами, але місцезнаходження встановлено на карті", - "coordinates_invalid_format": "Невірний формат координат. Використовуйте формат: широта, довгота", - "coordinates_no_address": "Координати встановлено на карті, але адресу не знайдено", - "coordinates_out_of_range": "Координати поза діапазоном. Широта має бути від -90 до 90, довгота від -180 до 180", - "coordinates_placeholder": "Введіть GPS-координати (напр., 37.7749, -122.4194)", - "coordinates_required": "Будь ласка, введіть координати для пошуку", + "coordinates_incomplete": "Введіть широту й довготу", + "coordinates_invalid": "Перевірте широту й довготу: потрібні обидві, у десяткових градусах", "create": "Створити", "create_error": "Помилка створення виклику", "create_new_call": "Створити новий виклик", @@ -277,6 +276,9 @@ "indoor_location": "Розташування в будівлі", "invalid_priority": "Обрано невірний пріоритет. Будь ласка, виберіть дійсний пріоритет.", "invalid_type": "Обрано невірний тип. Будь ласка, виберіть дійсний тип виклику.", + "latitude": "Широта", + "latitude_invalid": "Введіть широту в десяткових градусах, від -90 до 90", + "latitude_placeholder": "напр. 39.122639", "loading": "Завантаження викликів...", "loading_calls": "Завантаження викликів...", "name": "Назва", @@ -323,6 +325,7 @@ "selected": "вибрано", "title": "Виклики", "type": "Тип", + "udf_select_placeholder": "Виберіть {{field}}", "udf_suggestions": "Пропозиції", "units": "Підрозділи", "users": "Користувачі", @@ -798,6 +801,11 @@ "call_selected": "Виклик вибрано", "unit_selected": "Підрозділ вибрано", "unit_deselected": "Вибір підрозділу скасовано", + "unit_map_focus_crew_located": "Визначено місцезнаходження {{located}} з {{total}} членів екіпажу", + "unit_map_focus_no_crew": "Екіпаж не призначено", + "unit_map_focus_no_unit_position": "Підрозділ не передає позицію", + "unit_map_focus_no_positions": "Немає позицій для цього підрозділу та його екіпажу", + "unit_map_focus_recenter": "Центрувати на підрозділі", "personnel_selected": "Персонал вибрано", "personnel_deselected": "Вибір персоналу скасовано", "loading_map": "Завантаження карти...", @@ -1193,7 +1201,8 @@ "chat": "Чат", "assistant": "Асистент", "pending_calls": "Виклики в очікуванні", - "awaiting_resources": "Очікують ресурсів" + "awaiting_resources": "Очікують ресурсів", + "archived_calls": "Архівні виклики" }, "mfa": { "account": { @@ -1935,6 +1944,65 @@ "table_address": "Адреса", "table_scheduled": "Заплановано на" }, + "archived_calls": { + "title": "Архівні виклики", + "loading": "Завантаження архівних викликів...", + "no_archived_calls": "Немає архівних викликів", + "no_archived_calls_description": "За {{year}} рік немає закритих викликів.", + "no_matches": "Немає відповідних викликів", + "no_matches_description": "Жоден архівний виклик не відповідає пошуку та фільтрам.", + "search": "Пошук архівних викликів...", + "year": "Рік", + "filters": "Фільтри", + "clear_filters": "Скинути фільтри", + "logged_between": "Зареєстровано між", + "from_date": "З", + "to_date": "По", + "showing": "{{count}} з {{total}} викликів", + "table_state": "Закрито як", + "table_logged": "Зареєстровано", + "table_closed": "Закрито", + "add": "Додати архівний виклик", + "add_title": "Додати архівний виклик", + "add_description": "Введіть виклик, який уже відбувся. Він зберігається як закритий, і ніхто не отримує сповіщення.", + "section_call": "Виклик", + "section_closing": "Хронологія та закриття", + "logged_on": "Зареєстровано", + "closed_on": "Закрито", + "closed_on_helper": "Залиште порожнім, щоб використати поточний час.", + "closing_notes": "Нотатки закриття", + "closing_notes_placeholder": "Як було вирішено виклик", + "section_location": "Місцезнаходження", + "address_helper": "Без координат адресу буде визначено для карти.", + "latitude": "Широта", + "longitude": "Довгота", + "section_contact": "Контакт та ідентифікатори", + "reference_id": "Довідковий ID", + "external_id": "Зовнішній ID", + "no_type": "Без типу", + "section_dispatched": "Направлені ресурси", + "dispatched_hint": "Лише записується у виклику. Ніхто не отримує сповіщення.", + "select_dispatched": "Вибрати ресурси", + "dispatched_none": "Нічого не записано", + "dispatched_everyone": "Усі", + "dispatched_summary": "Особи: {{users}}, групи: {{groups}}, ролі: {{roles}}, підрозділи: {{units}}", + "section_numbering": "Нумерація викликів", + "recalculate_numbers": "Перерахувати номери викликів", + "recalculate_numbers_hint": "Перенумеровує виклики року в порядку їх реєстрації, щоб цей виклик зайняв своє місце в послідовності.", + "save": "Зберегти архівний виклик", + "save_success": "Архівний виклик збережено", + "save_error": "Не вдалося зберегти архівний виклик", + "name_required": "Назва обов'язкова", + "nature_required": "Характер обов'язковий", + "priority_required": "Виберіть пріоритет", + "state_required": "Виберіть, як було закрито виклик", + "logged_on_required": "Вкажіть, коли зареєстровано виклик", + "logged_on_future": "Виклик не може бути зареєстрований у майбутньому", + "closed_on_invalid": "Введіть дійсний час закриття", + "closed_on_before_logged": "Виклик не може бути закритий до його реєстрації", + "closed_on_future": "Виклик не може бути закритий у майбутньому", + "coordinates_invalid": "Введіть широту й довготу в десяткових градусах або залиште обидва поля порожніми" + }, "pending_calls": { "title": "Виклики в очікуванні", "loading": "Завантаження викликів в очікуванні...",