From a1845a65395b8f58f97f9c5aaaa493d98fbd1dcf Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 23 Sep 2026 07:50:12 -0700 Subject: [PATCH 1/8] RG-T55 Fixes, RMS, Checklists, CTRs/DTRs --- src/api/calls/__tests__/callSiteInfo.test.ts | 48 ++ src/api/calls/callSiteInfo.ts | 15 + src/api/calls/calls.ts | 12 + .../checklists/__tests__/checklists.test.ts | 18 + src/api/checklists/checklists.ts | 28 + .../contacts/__tests__/contactFiles.test.ts | 136 ++++ .../__tests__/contactPreplans.test.ts | 102 +++ src/api/contacts/contactFiles.ts | 62 ++ src/api/contacts/contactPreplans.ts | 53 ++ src/api/inventory/inventory.ts | 30 + .../operations/__tests__/operations.test.ts | 89 +++ src/api/operations/operations.ts | 73 ++ src/api/records/deployments.ts | 54 ++ src/api/records/field-records.ts | 110 +++ src/api/records/record-uploads.ts | 107 +++ src/api/records/records.ts | 50 ++ src/app/(app)/_layout.tsx | 4 + src/app/(app)/checklists.tsx | 148 ++++ src/app/(app)/inventory/_layout.tsx | 6 + src/app/(app)/inventory/count/[id].tsx | 175 +++++ src/app/(app)/inventory/index.tsx | 128 ++++ src/app/(app)/operations/[id].tsx | 258 +++++++ src/app/(app)/operations/_layout.tsx | 6 + src/app/(app)/operations/index.tsx | 77 ++ src/app/(app)/records.tsx | 209 ++++++ src/app/call/[id].tsx | 44 +- src/app/call/__tests__/[id].security.test.tsx | 4 + src/app/call/__tests__/[id].test.tsx | 93 ++- src/app/records/[id].tsx | 211 ++++++ src/app/records/connectors/[id].tsx | 189 +++++ src/app/records/connectors/index.tsx | 112 +++ src/app/records/deployments/[id].tsx | 165 ++++ src/app/records/deployments/index.tsx | 98 +++ src/app/records/new.tsx | 245 ++++++ .../audio-stream-bottom-sheet.test.tsx | 169 +++++ .../audio-stream-bottom-sheet.tsx | 12 +- .../__tests__/activity-link-marker.test.tsx | 40 + src/components/calls/activity-link-marker.tsx | 38 + .../calls/call-site-info-tab-panel.tsx | 184 +++++ .../__tests__/checklist-calendar.test.tsx | 27 + .../__tests__/checklist-run-sheet.test.tsx | 52 ++ .../__tests__/signature-pad.test.tsx | 17 + .../checklists/checklist-calendar.tsx | 74 ++ .../checklists/checklist-run-sheet.tsx | 343 +++++++++ src/components/checklists/signature-pad.tsx | 69 ++ .../__tests__/contact-details-sheet.test.tsx | 1 + .../contacts/contact-details-extra.tsx | 120 +++ .../contacts/contact-details-sheet.tsx | 42 +- .../contacts/contact-files-list.tsx | 129 ++++ .../contacts/contact-files-panel.tsx | 45 ++ .../contacts/contact-preplan-panel.tsx | 63 ++ src/components/contacts/preplan-summary.tsx | 254 +++++++ .../notifications/NotificationDetail.tsx | 12 +- .../notifications/NotificationInbox.tsx | 49 +- .../notification-references.test.tsx | 116 +++ src/components/operations/expenses-panel.tsx | 154 ++++ src/components/operations/mars-panel.tsx | 64 ++ src/components/operations/option-select.tsx | 40 + src/components/operations/scope-picker.tsx | 102 +++ .../operations/time-report-editor.tsx | 321 ++++++++ src/components/operations/usage-form.tsx | 119 +++ src/components/records/deployment-items.tsx | 245 ++++++ src/components/records/record-attachments.tsx | 266 +++++++ src/components/records/record-field.tsx | 188 +++++ src/components/records/record-form.tsx | 150 ++++ src/components/records/record-list-item.tsx | 101 +++ .../records/records-quick-create.tsx | 48 ++ src/components/sidebar/sidebar-content.tsx | 68 +- .../__tests__/status-bottom-sheet.test.tsx | 386 ++++++++-- .../status-gps-integration-working.test.tsx | 12 +- .../__tests__/status-gps-integration.test.tsx | 12 +- src/components/status/status-bottom-sheet.tsx | 336 ++++++--- .../use-checklist-live-updates.test.tsx | 35 + src/hooks/use-checklist-live-updates.ts | 56 ++ src/hooks/use-records-context.ts | 28 + src/lib/__tests__/activity-link-kind.test.ts | 34 + src/lib/__tests__/status-destination.test.ts | 58 ++ src/lib/activity-link-kind.ts | 30 + src/lib/checklists/__tests__/fixtures.ts | 7 + src/lib/checklists/__tests__/sync.test.ts | 59 ++ .../checklists/__tests__/translations.test.ts | 11 + src/lib/checklists/__tests__/vault.test.ts | 31 + src/lib/checklists/form.ts | 24 + src/lib/checklists/sync.ts | 56 ++ src/lib/checklists/vault.ts | 55 ++ src/lib/contacts/__tests__/format.test.ts | 27 + src/lib/contacts/format.ts | 64 ++ src/lib/inventory/__tests__/count.test.ts | 54 ++ src/lib/inventory/count.ts | 98 +++ src/lib/media/photo.ts | 47 ++ .../__tests__/inbox-reference.test.ts | 34 + src/lib/notifications/inbox-reference.ts | 32 + src/lib/operations/__tests__/time.test.ts | 141 ++++ src/lib/operations/capabilities.ts | 20 + src/lib/operations/time.ts | 244 ++++++ src/lib/records/__tests__/deployments.test.ts | 101 +++ src/lib/records/__tests__/fixtures.ts | 149 ++++ src/lib/records/__tests__/schema.test.ts | 115 +++ src/lib/records/__tests__/uploads.test.ts | 172 +++++ src/lib/records/deployments.ts | 105 +++ src/lib/records/schema.ts | 335 +++++++++ src/lib/records/uploads.ts | 191 +++++ src/lib/status-destination.ts | 71 ++ src/models/offline-queue/queued-event.ts | 1 + src/models/v4/calls/callResultData.ts | 7 + src/models/v4/calls/callSiteInfoResult.ts | 46 ++ .../v4/calls/dispatchedEventResultData.ts | 8 + .../v4/calls/statusDestinationSources.ts | 15 + src/models/v4/checklists/index.ts | 172 +++++ .../v4/contactFiles/contactFilesResult.ts | 57 ++ .../v4/contacts/contactPreplanResult.ts | 156 ++++ src/models/v4/contacts/contactResultData.ts | 29 + src/models/v4/inventory/index.ts | 99 +++ src/models/v4/operations/index.ts | 281 +++++++ src/models/v4/records/deployments.ts | 198 +++++ src/models/v4/records/index.ts | 444 +++++++++++ .../offline-event-manager.service.test.ts | 93 +++ src/services/offline-event-manager.service.ts | 86 ++- .../calls/__tests__/site-info-store.test.ts | 131 ++++ src/stores/calls/site-info-store.ts | 53 ++ src/stores/checklists/__tests__/store.test.ts | 115 +++ src/stores/checklists/store.ts | 378 ++++++++++ src/stores/contacts/preplan-store.ts | 84 +++ src/stores/contacts/store.ts | 42 +- src/stores/feature-flags/store.ts | 40 + src/stores/inventory/__tests__/store.test.ts | 85 +++ src/stores/inventory/store.ts | 143 ++++ .../offline-queue/__tests__/store.test.ts | 14 + src/stores/offline-queue/store.ts | 13 +- src/stores/operations/__tests__/store.test.ts | 173 +++++ src/stores/operations/store.ts | 311 ++++++++ .../__tests__/deployments-store.test.ts | 227 ++++++ src/stores/records/__tests__/store.test.ts | 287 +++++++ src/stores/records/deployments-store.ts | 217 ++++++ src/stores/records/store.ts | 611 +++++++++++++++ src/stores/signalr/signalr-store.ts | 1 + src/stores/status/__tests__/store.test.ts | 43 +- src/stores/status/store.ts | 6 +- src/translations/ar.json | 709 +++++++++++++++++- src/translations/de.json | 709 +++++++++++++++++- src/translations/el.json | 709 +++++++++++++++++- src/translations/en.json | 709 +++++++++++++++++- src/translations/es.json | 709 +++++++++++++++++- src/translations/fr.json | 709 +++++++++++++++++- src/translations/it.json | 709 +++++++++++++++++- src/translations/pl.json | 709 +++++++++++++++++- src/translations/sv.json | 709 +++++++++++++++++- src/translations/uk.json | 709 +++++++++++++++++- src/types/notification.ts | 2 +- 149 files changed, 21606 insertions(+), 263 deletions(-) create mode 100644 src/api/calls/__tests__/callSiteInfo.test.ts create mode 100644 src/api/calls/callSiteInfo.ts create mode 100644 src/api/checklists/__tests__/checklists.test.ts create mode 100644 src/api/checklists/checklists.ts create mode 100644 src/api/contacts/__tests__/contactFiles.test.ts create mode 100644 src/api/contacts/__tests__/contactPreplans.test.ts create mode 100644 src/api/contacts/contactFiles.ts create mode 100644 src/api/contacts/contactPreplans.ts create mode 100644 src/api/inventory/inventory.ts create mode 100644 src/api/operations/__tests__/operations.test.ts create mode 100644 src/api/operations/operations.ts create mode 100644 src/api/records/deployments.ts create mode 100644 src/api/records/field-records.ts create mode 100644 src/api/records/record-uploads.ts create mode 100644 src/api/records/records.ts create mode 100644 src/app/(app)/checklists.tsx create mode 100644 src/app/(app)/inventory/_layout.tsx create mode 100644 src/app/(app)/inventory/count/[id].tsx create mode 100644 src/app/(app)/inventory/index.tsx create mode 100644 src/app/(app)/operations/[id].tsx create mode 100644 src/app/(app)/operations/_layout.tsx create mode 100644 src/app/(app)/operations/index.tsx create mode 100644 src/app/(app)/records.tsx create mode 100644 src/app/records/[id].tsx create mode 100644 src/app/records/connectors/[id].tsx create mode 100644 src/app/records/connectors/index.tsx create mode 100644 src/app/records/deployments/[id].tsx create mode 100644 src/app/records/deployments/index.tsx create mode 100644 src/app/records/new.tsx create mode 100644 src/components/audio-stream/__tests__/audio-stream-bottom-sheet.test.tsx create mode 100644 src/components/calls/__tests__/activity-link-marker.test.tsx create mode 100644 src/components/calls/activity-link-marker.tsx create mode 100644 src/components/calls/call-site-info-tab-panel.tsx create mode 100644 src/components/checklists/__tests__/checklist-calendar.test.tsx create mode 100644 src/components/checklists/__tests__/checklist-run-sheet.test.tsx create mode 100644 src/components/checklists/__tests__/signature-pad.test.tsx create mode 100644 src/components/checklists/checklist-calendar.tsx create mode 100644 src/components/checklists/checklist-run-sheet.tsx create mode 100644 src/components/checklists/signature-pad.tsx create mode 100644 src/components/contacts/contact-details-extra.tsx create mode 100644 src/components/contacts/contact-files-list.tsx create mode 100644 src/components/contacts/contact-files-panel.tsx create mode 100644 src/components/contacts/contact-preplan-panel.tsx create mode 100644 src/components/contacts/preplan-summary.tsx create mode 100644 src/components/notifications/__tests__/notification-references.test.tsx create mode 100644 src/components/operations/expenses-panel.tsx create mode 100644 src/components/operations/mars-panel.tsx create mode 100644 src/components/operations/option-select.tsx create mode 100644 src/components/operations/scope-picker.tsx create mode 100644 src/components/operations/time-report-editor.tsx create mode 100644 src/components/operations/usage-form.tsx create mode 100644 src/components/records/deployment-items.tsx create mode 100644 src/components/records/record-attachments.tsx create mode 100644 src/components/records/record-field.tsx create mode 100644 src/components/records/record-form.tsx create mode 100644 src/components/records/record-list-item.tsx create mode 100644 src/components/records/records-quick-create.tsx create mode 100644 src/hooks/__tests__/use-checklist-live-updates.test.tsx create mode 100644 src/hooks/use-checklist-live-updates.ts create mode 100644 src/hooks/use-records-context.ts create mode 100644 src/lib/__tests__/activity-link-kind.test.ts create mode 100644 src/lib/__tests__/status-destination.test.ts create mode 100644 src/lib/activity-link-kind.ts create mode 100644 src/lib/checklists/__tests__/fixtures.ts create mode 100644 src/lib/checklists/__tests__/sync.test.ts create mode 100644 src/lib/checklists/__tests__/translations.test.ts create mode 100644 src/lib/checklists/__tests__/vault.test.ts create mode 100644 src/lib/checklists/form.ts create mode 100644 src/lib/checklists/sync.ts create mode 100644 src/lib/checklists/vault.ts create mode 100644 src/lib/contacts/__tests__/format.test.ts create mode 100644 src/lib/contacts/format.ts create mode 100644 src/lib/inventory/__tests__/count.test.ts create mode 100644 src/lib/inventory/count.ts create mode 100644 src/lib/media/photo.ts create mode 100644 src/lib/notifications/__tests__/inbox-reference.test.ts create mode 100644 src/lib/notifications/inbox-reference.ts create mode 100644 src/lib/operations/__tests__/time.test.ts create mode 100644 src/lib/operations/capabilities.ts create mode 100644 src/lib/operations/time.ts create mode 100644 src/lib/records/__tests__/deployments.test.ts create mode 100644 src/lib/records/__tests__/fixtures.ts create mode 100644 src/lib/records/__tests__/schema.test.ts create mode 100644 src/lib/records/__tests__/uploads.test.ts create mode 100644 src/lib/records/deployments.ts create mode 100644 src/lib/records/schema.ts create mode 100644 src/lib/records/uploads.ts create mode 100644 src/lib/status-destination.ts create mode 100644 src/models/v4/calls/callSiteInfoResult.ts create mode 100644 src/models/v4/calls/statusDestinationSources.ts create mode 100644 src/models/v4/checklists/index.ts create mode 100644 src/models/v4/contactFiles/contactFilesResult.ts create mode 100644 src/models/v4/contacts/contactPreplanResult.ts create mode 100644 src/models/v4/inventory/index.ts create mode 100644 src/models/v4/operations/index.ts create mode 100644 src/models/v4/records/deployments.ts create mode 100644 src/models/v4/records/index.ts create mode 100644 src/stores/calls/__tests__/site-info-store.test.ts create mode 100644 src/stores/calls/site-info-store.ts create mode 100644 src/stores/checklists/__tests__/store.test.ts create mode 100644 src/stores/checklists/store.ts create mode 100644 src/stores/contacts/preplan-store.ts create mode 100644 src/stores/inventory/__tests__/store.test.ts create mode 100644 src/stores/inventory/store.ts create mode 100644 src/stores/operations/__tests__/store.test.ts create mode 100644 src/stores/operations/store.ts create mode 100644 src/stores/records/__tests__/deployments-store.test.ts create mode 100644 src/stores/records/__tests__/store.test.ts create mode 100644 src/stores/records/deployments-store.ts create mode 100644 src/stores/records/store.ts diff --git a/src/api/calls/__tests__/callSiteInfo.test.ts b/src/api/calls/__tests__/callSiteInfo.test.ts new file mode 100644 index 00000000..d263177e --- /dev/null +++ b/src/api/calls/__tests__/callSiteInfo.test.ts @@ -0,0 +1,48 @@ +import { createApiEndpoint } from '../../common/client'; +import { getCallSiteInfo } from '../callSiteInfo'; + +jest.mock('../../common/client', () => { + const get = jest.fn(); + return { + createApiEndpoint: jest.fn(() => ({ get })), + __mockGet: get, + }; +}); + +const { __mockGet: mockGet } = jest.requireMock('../../common/client') as { __mockGet: jest.Mock }; + +// Endpoints are created at module load; capture them before beforeEach clears the mock. +const registeredPaths = (createApiEndpoint as jest.Mock).mock.calls.map((c) => c[0]); + +describe('callSiteInfo api', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('targets Calls/GetCallSiteInfo', () => { + expect(registeredPaths).toEqual(['/Calls/GetCallSiteInfo']); + }); + + it('passes callId as a query parameter and returns the body', async () => { + const payload = { Data: { CallId: '42', IsProtected: false, Contacts: [] } }; + mockGet.mockResolvedValueOnce({ data: payload }); + + await expect(getCallSiteInfo('42')).resolves.toBe(payload); + expect(mockGet).toHaveBeenCalledWith({ callId: '42' }, undefined); + }); + + it('forwards the abort signal', async () => { + const controller = new AbortController(); + mockGet.mockResolvedValueOnce({ data: { Data: null } }); + + await getCallSiteInfo('42', controller.signal); + + expect(mockGet).toHaveBeenCalledWith({ callId: '42' }, controller.signal); + }); + + it('propagates transport errors', async () => { + mockGet.mockRejectedValueOnce(new Error('Request failed with status code 403')); + + await expect(getCallSiteInfo('42')).rejects.toThrow('403'); + }); +}); diff --git a/src/api/calls/callSiteInfo.ts b/src/api/calls/callSiteInfo.ts new file mode 100644 index 00000000..cd770fd2 --- /dev/null +++ b/src/api/calls/callSiteInfo.ts @@ -0,0 +1,15 @@ +import { type CallSiteInfoResult } from '@/models/v4/calls/callSiteInfoResult'; + +import { createApiEndpoint } from '../common/client'; + +// v4 Calls/GetCallSiteInfo (Contacts plan Phase A, decision 3b): every contact linked to a call with its +// pre-plan, premise hazards, live alert notes and site file metadata in one round trip. Needs Call_View. +// The client attaches the Protected Data Grant header automatically; without one, a protected +// department's contact identity, note text and pre-plan text come back REDACTED. + +const getCallSiteInfoApi = createApiEndpoint('/Calls/GetCallSiteInfo'); + +export const getCallSiteInfo = async (callId: string, signal?: AbortSignal) => { + const response = await getCallSiteInfoApi.get({ callId }, signal); + return response.data; +}; diff --git a/src/api/calls/calls.ts b/src/api/calls/calls.ts index 23f611eb..89ddea15 100644 --- a/src/api/calls/calls.ts +++ b/src/api/calls/calls.ts @@ -49,6 +49,10 @@ export interface CreateCallRequest { type?: string; contactName?: string; contactInfo?: string; + /** Primary Contact (premises/customer record) to link; the contact must belong to the department. */ + contactId?: string | null; + /** Additional Contacts to link. On update, supplying either list replaces the existing links; omitting both leaves them alone. */ + additionalContactIds?: string[]; what3words?: string; plusCode?: string; dispatchUsers?: string[]; @@ -71,6 +75,10 @@ export interface UpdateCallRequest { type?: string; contactName?: string; contactInfo?: string; + /** Primary Contact (premises/customer record) to link; the contact must belong to the department. */ + contactId?: string | null; + /** Additional Contacts to link. On update, supplying either list replaces the existing links; omitting both leaves them alone. */ + additionalContactIds?: string[]; what3words?: string; plusCode?: string; dispatchUsers?: string[]; @@ -126,6 +134,8 @@ export const createCall = async (callData: CreateCallRequest) => { Type: callData.type || '', ContactName: callData.contactName || '', ContactInfo: callData.contactInfo || '', + ...(callData.contactId !== undefined ? { ContactId: callData.contactId || '' } : {}), + ...(callData.additionalContactIds !== undefined ? { AdditionalContactIds: callData.additionalContactIds } : {}), What3Words: callData.what3words || '', PlusCode: callData.plusCode || '', DispatchList: dispatchList, @@ -159,6 +169,8 @@ export const updateCall = async (callData: UpdateCallRequest) => { Type: callData.type || '', ContactName: callData.contactName || '', ContactInfo: callData.contactInfo || '', + ...(callData.contactId !== undefined ? { ContactId: callData.contactId || '' } : {}), + ...(callData.additionalContactIds !== undefined ? { AdditionalContactIds: callData.additionalContactIds } : {}), What3Words: callData.what3words || '', PlusCode: callData.plusCode || '', DispatchList: dispatchList, diff --git a/src/api/checklists/__tests__/checklists.test.ts b/src/api/checklists/__tests__/checklists.test.ts new file mode 100644 index 00000000..b1fe3c04 --- /dev/null +++ b/src/api/checklists/__tests__/checklists.test.ts @@ -0,0 +1,18 @@ +jest.mock('@/api/common/client', () => ({ api: { get: jest.fn(), post: jest.fn() } })); +import { api } from '@/api/common/client'; +import { getDueChecklists, previewChecklistOccurrence, startChecklistRun, saveChecklistRun, uploadChecklistImage } from '@/api/checklists/checklists'; +import { run } from '@/lib/checklists/__tests__/fixtures'; +beforeEach(()=>{jest.clearAllMocks();jest.mocked(api.get).mockResolvedValue({data:{Data:run()}});jest.mocked(api.post).mockResolvedValue({data:{Data:run()}});}); +it('scopes Unit checks by active unit and Responder checks to the current user',async()=>{ + await getDueChecklists(2,'12'); expect(api.get).toHaveBeenCalledWith('/ChecklistRuns/GetDueChecklists',{params:{page:2,unitId:'12',forCurrentUser:false}}); + await getDueChecklists(0); expect(api.get).toHaveBeenLastCalledWith('/ChecklistRuns/GetDueChecklists',{params:{page:0,unitId:undefined,forCurrentUser:true}}); +}); +it('uses read-only previews and pins the client GUID and published version when starting',async()=>{ + await previewChecklistOccurrence('occurrence');expect(api.get).toHaveBeenCalledWith('/ChecklistRuns/GetChecklistRun',{params:{occurrenceId:'occurrence'}}); + const input={CompletionId:run().Id,DefinitionId:'definition',VersionId:'version-1',TargetId:'77'};await startChecklistRun(input);expect(api.post).toHaveBeenCalledWith('/ChecklistRuns/StartChecklistRun',input); +}); +it('sends revisioned evidence in memory and keeps the supplied revision on final submission',async()=>{ + await uploadChecklistImage(run().Id,3,{id:'image',itemId:'first',name:'photo.png',contentType:'image/png',base64:'synthetic'}); + expect(api.post).toHaveBeenCalledWith('/ChecklistRuns/UploadChecklistRunFile',{Id:run().Id,Revision:3,ItemId:'first',Name:'photo.png',ContentType:'image/png',Data:'synthetic'}); + await saveChecklistRun(run().Id,{Revision:4,Answers:[]},true);expect(api.post).toHaveBeenCalledWith('/ChecklistRuns/CompleteChecklistRun',{Id:run().Id,Input:{Revision:4,Answers:[]}}); +}); diff --git a/src/api/checklists/checklists.ts b/src/api/checklists/checklists.ts new file mode 100644 index 00000000..d0890687 --- /dev/null +++ b/src/api/checklists/checklists.ts @@ -0,0 +1,28 @@ +import { Buffer } from 'buffer'; + +import { api } from '@/api/common/client'; +import type { ChecklistAccess, ChecklistDuePage, ChecklistHistory, ChecklistImage, ChecklistInput, ChecklistResult, ChecklistRun, ChecklistStart } from '@/models/v4/checklists'; + +// Deliberately uncached. The shared client attaches only the current in-memory ADP grant. +export const getChecklistAccess = async () => (await api.get>('/ChecklistRuns/GetChecklistAccess')).data.Data; +export const getDueChecklists = async (page: number, unitId?: string) => + (await api.get>('/ChecklistRuns/GetDueChecklists', { params: { page, unitId, forCurrentUser: !unitId } })).data.Data; +export const getChecklistRun = async (id: string) => (await api.get>('/ChecklistRuns/GetChecklistRun', { params: { id } })).data.Data; +export const previewChecklistOccurrence = async (occurrenceId: string) => (await api.get>('/ChecklistRuns/GetChecklistRun', { params: { occurrenceId } })).data.Data; +export const startChecklistRun = async (input: ChecklistStart) => (await api.post>('/ChecklistRuns/StartChecklistRun', input)).data.Data; +export const saveChecklistRun = async (id: string, input: ChecklistInput, submit: boolean) => + (await api.post>(`/ChecklistRuns/${submit ? 'CompleteChecklistRun' : 'SaveChecklistRunProgress'}`, { Id: id, Input: input })).data.Data; +export const uploadChecklistImage = async (id: string, revision: number, image: ChecklistImage) => + (await api.post>('/ChecklistRuns/UploadChecklistRunFile', { Id: id, Revision: revision, ItemId: image.itemId, Name: image.name, ContentType: image.contentType, Data: image.base64 })).data + .Data; +export const attestChecklistRun = async (id: string, submissionHash: string, attestation: string) => + (await api.post>('/ChecklistRuns/AttestChecklistRun', { Id: id, SubmissionHash: submissionHash, Attestation: attestation })).data.Data; +export const getChecklistHistory = async (page: number, unitId?: string) => (await api.get>('/ChecklistRuns/GetChecklistHistory', { params: { page, unitId } })).data; + +// Evidence is returned to the runner in memory; never create a shared cache file for a protected read. +export const getChecklistImage = async (id: string) => { + const response = await api.get('/ChecklistRuns/GetChecklistRunFile', { params: { id }, responseType: 'arraybuffer' }); + const contentType = String(response.headers['content-type'] ?? '').split(';')[0]; + if (!['image/png', 'image/jpeg'].includes(contentType) || response.data.byteLength > 10 * 1024 * 1024) throw new Error('denied'); + return `data:${contentType};base64,${Buffer.from(response.data).toString('base64')}`; +}; diff --git a/src/api/contacts/__tests__/contactFiles.test.ts b/src/api/contacts/__tests__/contactFiles.test.ts new file mode 100644 index 00000000..4ff80e85 --- /dev/null +++ b/src/api/contacts/__tests__/contactFiles.test.ts @@ -0,0 +1,136 @@ +import { getCallAttachmentFile } from '@/api/calls/callFiles'; +import { type ContactFileResultData } from '@/models/v4/contactFiles/contactFilesResult'; + +import { createApiEndpoint } from '../../common/client'; +import { deleteContactFile, getContactFileBase64, getContactFiles, uploadContactFile } from '../contactFiles'; + +jest.mock('../../common/client', () => { + const get = jest.fn(); + const post = jest.fn(); + const del = jest.fn(); + return { + createApiEndpoint: jest.fn(() => ({ get, post, delete: del })), + __mockGet: get, + __mockPost: post, + __mockDelete: del, + }; +}); + +jest.mock('@/api/calls/callFiles', () => ({ + getCallAttachmentFile: jest.fn(), +})); + +const { + __mockGet: mockGet, + __mockPost: mockPost, + __mockDelete: mockDelete, +} = jest.requireMock('../../common/client') as { __mockGet: jest.Mock; __mockPost: jest.Mock; __mockDelete: jest.Mock }; + +const mockGetCallAttachmentFile = getCallAttachmentFile as jest.MockedFunction; + +// Endpoints are created at module load; capture them before beforeEach clears the mock. +const registeredPaths = (createApiEndpoint as jest.Mock).mock.calls.map((c) => c[0]); + +// Minimal FileReader: hands back a data URL for whatever blob it is given. +Object.defineProperty(global, 'FileReader', { + writable: true, + value: class MockFileReader { + result: string | ArrayBuffer | null = null; + onload: ((event: unknown) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + readAsDataURL(_blob: Blob) { + this.result = 'data:application/pdf;base64,U0lHTkVE'; + this.onload?.({}); + } + }, +}); + +const makeFile = (overrides: Partial = {}): ContactFileResultData => ({ + IsProtected: false, + RedactedFields: [], + Id: '10', + ContactId: 'c1', + Type: 1, + TypeName: 'PrePlan', + Name: 'Pre-plan', + FileName: 'preplan.pdf', + Mime: 'application/pdf', + Size: 1024, + Url: 'https://api.example/api/v4/ContactFiles/GetFile?q=signed', + Timestamp: '2026-09-07T00:00:00Z', + ...overrides, +}); + +describe('contactFiles api', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('registers the three v4 ContactFiles endpoints', () => { + expect(registeredPaths).toEqual(expect.arrayContaining(['/ContactFiles/GetFilesForContact', '/ContactFiles/UploadContactFile', '/ContactFiles/DeleteContactFile'])); + }); + + it('getContactFiles defaults to metadata only and omits type when not given', async () => { + const payload = { Data: [makeFile()] }; + mockGet.mockResolvedValueOnce({ data: payload }); + + await expect(getContactFiles('c1')).resolves.toBe(payload); + expect(mockGet).toHaveBeenCalledWith({ contactId: 'c1', includeData: false }, undefined); + }); + + it('getContactFiles passes includeData, type and the abort signal', async () => { + const controller = new AbortController(); + mockGet.mockResolvedValueOnce({ data: { Data: [] } }); + + await getContactFiles('c1', true, 2, controller.signal); + + expect(mockGet).toHaveBeenCalledWith({ contactId: 'c1', includeData: true, type: 2 }, controller.signal); + }); + + it('uploadContactFile posts the input and deleteContactFile sends the id', async () => { + const input = { ContactId: 'c1', Type: 3, Name: 'Front', FileName: 'front.jpg', Data: 'AAAA' }; + mockPost.mockResolvedValueOnce({ data: { Id: '11' } }); + mockDelete.mockResolvedValueOnce({ data: {} }); + + await expect(uploadContactFile(input)).resolves.toEqual({ Id: '11' }); + expect(mockPost).toHaveBeenCalledWith(input); + + await deleteContactFile(11); + expect(mockDelete).toHaveBeenCalledWith({ contactFileId: 11 }); + }); + + describe('getContactFileBase64', () => { + it('uses the signed Url when present and returns the bytes as base64', async () => { + mockGetCallAttachmentFile.mockResolvedValueOnce(new Blob(['SIGNED'])); + const file = makeFile(); + + await expect(getContactFileBase64(file)).resolves.toBe('U0lHTkVE'); + expect(mockGetCallAttachmentFile).toHaveBeenCalledWith(file.Url); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it('falls back to the includeData path for a protected file without a Url', async () => { + const file = makeFile({ IsProtected: true, Url: null }); + mockGet.mockResolvedValueOnce({ data: { Data: [makeFile({ Id: '9', Data: 'zzzz' }), { ...file, Data: 'aGVsbG8=' }] } }); + + await expect(getContactFileBase64(file)).resolves.toBe('aGVsbG8='); + + expect(mockGetCallAttachmentFile).not.toHaveBeenCalled(); + expect(mockGet).toHaveBeenCalledWith({ contactId: 'c1', includeData: true, type: 1 }, undefined); + }); + + it('throws the server reason when the protected bytes are withheld', async () => { + const file = makeFile({ IsProtected: true, Url: null }); + mockGet.mockResolvedValueOnce({ data: { Data: [{ ...file, Data: null, ProtectedReason: 'grant_required' }] } }); + + await expect(getContactFileBase64(file)).rejects.toThrow('grant_required'); + }); + + it('throws a generic reason when the file is missing from the response', async () => { + const file = makeFile({ IsProtected: true, Url: null }); + mockGet.mockResolvedValueOnce({ data: { Data: [] } }); + + await expect(getContactFileBase64(file)).rejects.toThrow('protected_access_denied'); + }); + }); +}); diff --git a/src/api/contacts/__tests__/contactPreplans.test.ts b/src/api/contacts/__tests__/contactPreplans.test.ts new file mode 100644 index 00000000..be10741d --- /dev/null +++ b/src/api/contacts/__tests__/contactPreplans.test.ts @@ -0,0 +1,102 @@ +import { createApiEndpoint } from '../../common/client'; +import { deleteContactHazard, deleteContactPreplan, getContactHazards, getContactPreplan, saveContactHazard, saveContactPreplan } from '../contactPreplans'; + +jest.mock('../../common/client', () => { + const get = jest.fn(); + const post = jest.fn(); + const del = jest.fn(); + return { + createApiEndpoint: jest.fn(() => ({ get, post, delete: del })), + __mockGet: get, + __mockPost: post, + __mockDelete: del, + }; +}); + +const { + __mockGet: mockGet, + __mockPost: mockPost, + __mockDelete: mockDelete, +} = jest.requireMock('../../common/client') as { __mockGet: jest.Mock; __mockPost: jest.Mock; __mockDelete: jest.Mock }; + +// Endpoints are created at module load; capture them before beforeEach clears the mock. +const registeredPaths = (createApiEndpoint as jest.Mock).mock.calls.map((c) => c[0]); + +describe('contactPreplans api', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('registers the six v4 Contacts pre-plan endpoints', () => { + expect(registeredPaths).toEqual( + expect.arrayContaining([ + '/Contacts/GetContactPreplan', + '/Contacts/SaveContactPreplan', + '/Contacts/DeleteContactPreplan', + '/Contacts/GetContactHazards', + '/Contacts/SaveContactHazard', + '/Contacts/DeleteContactHazard', + ]) + ); + }); + + it('getContactPreplan passes contactId and returns the body', async () => { + const payload = { Data: { ContactPreplanId: 'p1', ContactId: 'c1', RedactedFields: ['contactpreplans.gatecode'] } }; + mockGet.mockResolvedValueOnce({ data: payload }); + + await expect(getContactPreplan('c1')).resolves.toBe(payload); + expect(mockGet).toHaveBeenCalledWith({ contactId: 'c1' }, undefined); + }); + + it('getContactPreplan forwards the abort signal', async () => { + const controller = new AbortController(); + mockGet.mockResolvedValueOnce({ data: { Data: null } }); + + await getContactPreplan('c1', controller.signal); + + expect(mockGet).toHaveBeenCalledWith({ contactId: 'c1' }, controller.signal); + }); + + it('saveContactPreplan posts the input as the body', async () => { + const input = { ContactId: 'c1', ConstructionType: 2, RoofType: 1, OccupancyType: 3, HasOccupantsNeedingAssistance: false, TacticalSummary: 'Sprinklered', GateCode: '1234', HazmatOnSite: true }; + mockPost.mockResolvedValueOnce({ data: { Id: 'p1' } }); + + await expect(saveContactPreplan(input)).resolves.toEqual({ Id: 'p1' }); + expect(mockPost).toHaveBeenCalledWith(input); + expect(mockPost.mock.calls[0][0]).not.toBe(input); // spread copy, caller's object untouched + }); + + it('deleteContactPreplan sends contactId as a query parameter', async () => { + mockDelete.mockResolvedValueOnce({ data: {} }); + + await deleteContactPreplan('c1'); + + expect(mockDelete).toHaveBeenCalledWith({ contactId: 'c1' }); + }); + + it('getContactHazards passes contactId and returns the list body', async () => { + const payload = { Data: [{ ContactPreplanHazardId: 'h1', Severity: 3, ShouldAlert: true }] }; + mockGet.mockResolvedValueOnce({ data: payload }); + + await expect(getContactHazards('c1')).resolves.toBe(payload); + expect(mockGet).toHaveBeenCalledWith({ contactId: 'c1' }, undefined); + }); + + it('saveContactHazard posts the hazard and deleteContactHazard sends the hazard id', async () => { + const hazard = { ContactId: 'c1', ContactPreplanHazardId: 'h1', HazardType: 2, Severity: 4, Title: 'Propane tank', Description: '500 gal, NE corner', ShouldAlert: true }; + mockPost.mockResolvedValueOnce({ data: { Id: 'h1' } }); + mockDelete.mockResolvedValueOnce({ data: {} }); + + await expect(saveContactHazard(hazard)).resolves.toEqual({ Id: 'h1' }); + expect(mockPost).toHaveBeenCalledWith(hazard); + + await deleteContactHazard('h1'); + expect(mockDelete).toHaveBeenCalledWith({ contactPreplanHazardId: 'h1' }); + }); + + it('propagates transport errors', async () => { + mockGet.mockRejectedValueOnce(new Error('Network Error')); + + await expect(getContactPreplan('c1')).rejects.toThrow('Network Error'); + }); +}); diff --git a/src/api/contacts/contactFiles.ts b/src/api/contacts/contactFiles.ts new file mode 100644 index 00000000..b36d218b --- /dev/null +++ b/src/api/contacts/contactFiles.ts @@ -0,0 +1,62 @@ +import { getCallAttachmentFile } from '@/api/calls/callFiles'; +import { type ContactFileResultData, type ContactFilesResult, type DeleteContactFileResult, type UploadContactFileInput, type UploadContactFileResult } from '@/models/v4/contactFiles/contactFilesResult'; + +import { createApiEndpoint } from '../common/client'; + +// v4 ContactFiles (Contacts plan Phase A, A5): site documents, pre-plans, floor plans, photos and +// drawings attached to a contact. Metadata lists carry a signed anonymous Url; a protected department's +// enveloped file has no Url and is fetched with includeData under the caller's grant instead. + +const getContactFilesApi = createApiEndpoint('/ContactFiles/GetFilesForContact'); +const uploadContactFileApi = createApiEndpoint('/ContactFiles/UploadContactFile'); +const deleteContactFileApi = createApiEndpoint('/ContactFiles/DeleteContactFile'); + +export const getContactFiles = async (contactId: string, includeData = false, type?: number | null, signal?: AbortSignal) => { + const params: Record = { contactId, includeData }; + if (type !== undefined && type !== null) { + params.type = type; + } + const response = await getContactFilesApi.get(params, signal); + return response.data; +}; + +export const uploadContactFile = async (input: UploadContactFileInput) => { + const response = await uploadContactFileApi.post({ ...input }); + return response.data; +}; + +export const deleteContactFile = async (contactFileId: number) => { + const response = await deleteContactFileApi.delete({ contactFileId }); + return response.data; +}; + +const blobToBase64 = (blob: Blob): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + resolve(result.split(',')[1] || result); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + +/** + * Downloads a contact file's bytes as base64 (ready for expo-file-system). The signed Url is the fast + * path; when the file is protected (no Url), the bytes come from GetFilesForContact with includeData, + * which the server decrypts under the caller's grant — or refuses, in which case this throws rather than + * returning ciphertext. Base64 rather than Blob because React Native cannot build a Blob from bytes. + */ +export const getContactFileBase64 = async (file: ContactFileResultData): Promise => { + if (file.Url) { + return blobToBase64(await getCallAttachmentFile(file.Url)); + } + + const result = await getContactFiles(file.ContactId, true, file.Type); + const match = result.Data?.find((f) => f.Id === file.Id); + if (!match?.Data) { + throw new Error(match?.ProtectedReason || 'protected_access_denied'); + } + + return match.Data; +}; diff --git a/src/api/contacts/contactPreplans.ts b/src/api/contacts/contactPreplans.ts new file mode 100644 index 00000000..1e847580 --- /dev/null +++ b/src/api/contacts/contactPreplans.ts @@ -0,0 +1,53 @@ +import { + type ContactHazardsResult, + type ContactPreplanResult, + type DeleteContactHazardResult, + type DeleteContactPreplanResult, + type SaveContactHazardInput, + type SaveContactHazardResult, + type SaveContactPreplanInput, + type SaveContactPreplanResult, +} from '@/models/v4/contacts/contactPreplanResult'; + +import { createApiEndpoint } from '../common/client'; + +// v4 Contacts pre-plan and hazard endpoints (Contacts plan Phase A, A5). Reads need Contacts_View, +// writes Contacts_Update. The client attaches the Protected Data Grant header automatically; without +// one, a protected department's text fields come back REDACTED with RedactedFields naming them. + +const getContactPreplanApi = createApiEndpoint('/Contacts/GetContactPreplan'); +const saveContactPreplanApi = createApiEndpoint('/Contacts/SaveContactPreplan'); +const deleteContactPreplanApi = createApiEndpoint('/Contacts/DeleteContactPreplan'); +const getContactHazardsApi = createApiEndpoint('/Contacts/GetContactHazards'); +const saveContactHazardApi = createApiEndpoint('/Contacts/SaveContactHazard'); +const deleteContactHazardApi = createApiEndpoint('/Contacts/DeleteContactHazard'); + +export const getContactPreplan = async (contactId: string, signal?: AbortSignal) => { + const response = await getContactPreplanApi.get({ contactId }, signal); + return response.data; +}; + +export const saveContactPreplan = async (input: SaveContactPreplanInput) => { + const response = await saveContactPreplanApi.post({ ...input }); + return response.data; +}; + +export const deleteContactPreplan = async (contactId: string) => { + const response = await deleteContactPreplanApi.delete({ contactId }); + return response.data; +}; + +export const getContactHazards = async (contactId: string, signal?: AbortSignal) => { + const response = await getContactHazardsApi.get({ contactId }, signal); + return response.data; +}; + +export const saveContactHazard = async (input: SaveContactHazardInput) => { + const response = await saveContactHazardApi.post({ ...input }); + return response.data; +}; + +export const deleteContactHazard = async (contactPreplanHazardId: string) => { + const response = await deleteContactHazardApi.delete({ contactPreplanHazardId }); + return response.data; +}; diff --git a/src/api/inventory/inventory.ts b/src/api/inventory/inventory.ts new file mode 100644 index 00000000..94f0cb21 --- /dev/null +++ b/src/api/inventory/inventory.ts @@ -0,0 +1,30 @@ +import { api } from '@/api/common/client'; +import type { InventoryApiResult, InventoryCount, InventoryCountDetail, InventoryCountResult, InventoryEquipment, InventoryFieldAccess, InventoryPage } from '@/models/v4/inventory'; + +// v4 Inventory for the apparatus tablet: what the unit carries and the counts crews run against it. Every +// count call needs AdjustInventory at the unit's holder location; the server re-checks all of it. + +export const getInventoryAccess = async (unitId: number) => (await api.get>('/Inventory/GetAccess', { params: { unitId } })).data.Data; + +export const getUnitEquipment = async (unitId: number) => (await api.get>('/Inventory/GetUnitEquipment', { params: { unitId } })).data.Data; + +export const getCounts = async (locationId: string, page = 0) => (await api.get>>('/Inventory/GetCounts', { params: { page, locationId } })).data.Data; + +export const getCount = async (id: string) => (await api.get>('/Inventory/GetCount', { params: { id } })).data.Data; + +/** + * A field count of one unit location: its compartments and kit containers are included, the department's + * whole catalog is not padded in (that would exceed the 100-line count limit), and the snapshot fence covers + * only these positions so work elsewhere does not void the count. + */ +export const startCount = async (id: string, locationId: string, name: string, note?: string | null) => + (await api.post>('/Inventory/StartCount', { Id: id, LocationId: locationId, Name: name, Note: note ?? null, IncludeCatalogItems: false, IncludeChildLocations: true })).data + .Data; + +export const saveCount = async (countId: string, revision: number, lines: { Id: string; Quantity: number }[]) => + (await api.post>('/Inventory/SaveCount', { CountId: countId, Revision: revision, Lines: lines })).data.Data; + +export const completeCount = async (countId: string, revision: number, requestId: string) => + (await api.post>('/Inventory/CompleteCount', { CountId: countId, Revision: revision, RequestId: requestId })).data.Data; + +export const cancelCount = async (countId: string, revision: number) => (await api.post>('/Inventory/CancelCount', { CountId: countId, Revision: revision })).data; diff --git a/src/api/operations/__tests__/operations.test.ts b/src/api/operations/__tests__/operations.test.ts new file mode 100644 index 00000000..118c26f1 --- /dev/null +++ b/src/api/operations/__tests__/operations.test.ts @@ -0,0 +1,89 @@ +jest.mock('@/api/common/client', () => ({ api: { get: jest.fn(), post: jest.fn(), delete: jest.fn() } })); +import { api } from '@/api/common/client'; +import { + addResourceUsage, + approveTimeReport, + buildF42, + deleteExpense, + getDeployments, + getExpenses, + getTimeReports, + newTimeReport, + saveExpense, + saveTimeEntries, + signTimeReport, + submitTimeReport, + validateCalOesMarsWorkItem, +} from '@/api/operations/operations'; + +beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(api.get).mockResolvedValue({ data: { Data: [] } }); + jest.mocked(api.post).mockResolvedValue({ data: { Data: { Id: 'r-1', Entries: [] }, Errors: [], Warnings: [] } }); + jest.mocked(api.delete).mockResolvedValue({ data: { Status: 'Success' } }); +}); + +it('lists open deployments by default and everything on request', async () => { + await getDeployments(); + expect(api.get).toHaveBeenCalledWith('/Deployments/GetDeployments', { params: { active: true, skip: 0, take: 100 } }); + await getDeployments(false); + expect(api.get).toHaveBeenLastCalledWith('/Deployments/GetDeployments', { params: { active: false, skip: 0, take: 100 } }); + await getTimeReports('dep-1'); + expect(api.get).toHaveBeenLastCalledWith('/TimeReports/GetTimeReports', { params: { deploymentId: 'dep-1' } }); +}); + +it('opens crew and individual reports and saves only the local wall clock', async () => { + await newTimeReport('dep-1', '2026-09-19', { deploymentUnitId: 'du-1' }); + expect(api.post).toHaveBeenCalledWith('/TimeReports/NewTimeReport', { DeploymentId: 'dep-1', ReportDate: '2026-09-19', DeploymentUnitId: 'du-1', DeploymentPersonnelId: null }); + await newTimeReport('dep-1', '2026-09-19', { deploymentPersonnelId: 'dp-5' }); + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/NewTimeReport', { DeploymentId: 'dep-1', ReportDate: '2026-09-19', DeploymentUnitId: null, DeploymentPersonnelId: 'dp-5' }); + + const entry = { + Id: 'e-1', + SubjectType: 0, + DeploymentPersonnelId: 'p-1', + EntryType: 0, + StartTime: '2026-09-19T15:00:00.000Z', + EndTime: '2026-09-20T01:00:00.000Z', + StartLocal: '2026-09-19T08:00', + EndLocal: '2026-09-19T18:00', + PaidBreakMinutes: 0, + UnpaidBreakMinutes: 30, + AgencySuppliedMeals: true, + AgencySuppliedAccommodation: false, + SortOrder: 0, + Hours: 9.5, + }; + const saved = await saveTimeEntries('r-1', [entry]); + const { StartTime: _start, EndTime: _end, Hours: _hours, ...sent } = entry; + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SaveTimeEntries', { TimeReportId: 'r-1', Entries: [sent] }); + expect(saved.Errors).toEqual([]); + await submitTimeReport('r-1'); + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SubmitTimeReport', { Id: 'r-1' }); + await signTimeReport('r-1', true); + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SignTimeReport', { Id: 'r-1', ContractorSigned: true, CustomerSignerName: null }); + await signTimeReport('r-1', false, 'J. Agency'); + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SignTimeReport', { Id: 'r-1', ContractorSigned: false, CustomerSignerName: 'J. Agency' }); + await approveTimeReport('r-1'); + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/ApproveTimeReport', { Id: 'r-1' }); +}); + +it('files, lists and removes expenses with the receipt riding as base64', async () => { + await getExpenses('dep-1'); + expect(api.get).toHaveBeenLastCalledWith('/TimeReports/GetExpenses', { params: { deploymentId: 'dep-1' } }); + const input = { DeploymentId: 'dep-1', ExpenseDate: '2026-09-19', ExpenseType: 4, Amount: 88.4, ReceiptData: 'AAAA', ReceiptFileName: 'r.jpg', ReceiptContentType: 'image/jpeg' }; + await saveExpense(input); + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SaveExpense', input, { timeout: 60000 }); + await deleteExpense('x-1'); + expect(api.delete).toHaveBeenCalledWith('/TimeReports/DeleteExpense', { params: { id: 'x-1' } }); +}); + +it('posts usage readings and the F-42 draft / validation calls as the server expects them', async () => { + const input = { DeploymentId: 'dep-1', CallId: null, UnitId: 12, UsageDate: '2026-09-19T00:00:00', Phase: 2, StartOdometer: 100, EndOdometer: 160, DistanceUnit: 'mi', Distance: 60 }; + await addResourceUsage(input); + expect(api.post).toHaveBeenCalledWith('/FieldCost/AddResourceUsage', input); + await buildF42('dep-1'); + expect(api.post).toHaveBeenLastCalledWith('/CalOesMars/BuildF42', { DeploymentId: 'dep-1' }); + await validateCalOesMarsWorkItem('wi-1'); + expect(api.post).toHaveBeenLastCalledWith('/CalOesMars/Validate', null, { params: { id: 'wi-1' } }); +}); diff --git a/src/api/operations/operations.ts b/src/api/operations/operations.ts new file mode 100644 index 00000000..10352c8e --- /dev/null +++ b/src/api/operations/operations.ts @@ -0,0 +1,73 @@ +import { api } from '@/api/common/client'; +import type { + CalOesMarsAccess, + CalOesMarsQueueItem, + CalOesMarsValidation, + CalOesMarsWorkItem, + Deployment, + DeploymentAccess, + Expense, + ExpenseInput, + FieldCostAccess, + OperationsResult, + ResourceUsage, + ResourceUsageInput, + TimeEntry, + TimeReport, + TimeReportResponse, +} from '@/models/v4/operations'; + +// Deliberately uncached: access answers and open reports change under the person during a shift, and +// a stale "enabled" would let a screen render after the department turned the feature off. +export const getDeploymentAccess = async () => (await api.get>('/Deployments/GetAccess')).data.Data; +export const getDeployments = async (active = true) => (await api.get>('/Deployments/GetDeployments', { params: { active, skip: 0, take: 100 } })).data.Data; +export const getDeployment = async (id: string) => (await api.get>('/Deployments/GetDeployment', { params: { id } })).data.Data; +export const getDeploymentByCallId = async (callId: number) => (await api.get>('/Deployments/GetDeploymentByCallId', { params: { callId } })).data.Data; + +/** Who a new report covers: one deployed unit's crew (CTR), one person, or — managers only — the whole deployment. */ +export interface TimeReportScopeInput { + deploymentUnitId?: string | null; + deploymentPersonnelId?: string | null; +} + +// Reports come back with their entries so a day's report opens straight from the list. +export const getTimeReports = async (deploymentId: string) => (await api.get>('/TimeReports/GetTimeReports', { params: { deploymentId } })).data.Data; +export const getTimeReport = async (id: string) => (await api.get>('/TimeReports/GetTimeReport', { params: { id } })).data.Data; +export const newTimeReport = async (deploymentId: string, reportDate: string, scope: TimeReportScopeInput = {}) => + ( + await api.post('/TimeReports/NewTimeReport', { + DeploymentId: deploymentId, + ReportDate: reportDate, + DeploymentUnitId: scope.deploymentUnitId ?? null, + DeploymentPersonnelId: scope.deploymentPersonnelId ?? null, + }) + ).data; +// The server replaces the entries of the subjects the caller may write with what it is sent; everyone else's stay as stored. +// Only the local wall clock travels (StartLocal/EndLocal): the server converts it in the department's zone. +export const saveTimeEntries = async (timeReportId: string, entries: TimeEntry[]) => + ( + await api.post('/TimeReports/SaveTimeEntries', { + TimeReportId: timeReportId, + Entries: entries.map(({ StartTime: _start, EndTime: _end, Hours: _hours, ...entry }) => entry), + }) + ).data; +export const submitTimeReport = async (id: string) => (await api.post('/TimeReports/SubmitTimeReport', { Id: id })).data; +/** The crew boss signs as the acting user; the customer (agency) signer is a typed name. */ +export const signTimeReport = async (id: string, contractorSigned: boolean, customerSignerName?: string | null) => + (await api.post('/TimeReports/SignTimeReport', { Id: id, ContractorSigned: contractorSigned, CustomerSignerName: customerSignerName || null })).data; +export const approveTimeReport = async (id: string) => (await api.post('/TimeReports/ApproveTimeReport', { Id: id })).data; + +export const getExpenses = async (deploymentId: string) => (await api.get>('/TimeReports/GetExpenses', { params: { deploymentId } })).data.Data; +// Receipts ride as base64; allow a slower link than the default request timeout. +export const saveExpense = async (input: ExpenseInput) => (await api.post>('/TimeReports/SaveExpense', input, { timeout: 60000 })).data.Data; +export const deleteExpense = async (id: string) => (await api.delete>('/TimeReports/DeleteExpense', { params: { id } })).data; + +export const getFieldCostAccess = async () => (await api.get>('/FieldCost/GetAccess')).data.Data; +export const getResourceUsage = async (deploymentId: string) => (await api.get>('/FieldCost/GetResourceUsage', { params: { deploymentId } })).data.Data; +export const addResourceUsage = async (input: ResourceUsageInput) => (await api.post>('/FieldCost/AddResourceUsage', input)).data.Data; + +export const getCalOesMarsAccess = async () => (await api.get>('/CalOesMars/GetAccess')).data.Data; +export const getCalOesMarsQueue = async () => (await api.get>('/CalOesMars/GetQueue')).data.Data; +export const getCalOesMarsWorkItem = async (id: string) => (await api.get>('/CalOesMars/GetWorkItem', { params: { id } })).data.Data; +export const buildF42 = async (deploymentId: string) => (await api.post>('/CalOesMars/BuildF42', { DeploymentId: deploymentId })).data.Data; +export const validateCalOesMarsWorkItem = async (id: string) => (await api.post>('/CalOesMars/Validate', null, { params: { id } })).data.Data; diff --git a/src/api/records/deployments.ts b/src/api/records/deployments.ts new file mode 100644 index 00000000..c930415a --- /dev/null +++ b/src/api/records/deployments.ts @@ -0,0 +1,54 @@ +import { type RecordsApiResult } from '@/models/v4/records'; +import { type RecordDeploymentConnectorData, type RecordDeploymentConnectorRunData, type RecordDeploymentData, type RecordDeploymentReconciliationData } from '@/models/v4/records/deployments'; + +import { createApiEndpoint } from '../common/client'; + +// v4 RecordDeployments and RecordDeploymentConnectors (RMS plan section 4.1). Deployments need +// Record_View; every connector call is department administration and the server refuses anyone else. +// The only command here is Run ("read the feed now"). Create, update, enable, terms acknowledgement, +// token rotation and delete stay Web-first — a credential is never typed on a phone — so they are +// deliberately absent. + +const listDeploymentsApi = createApiEndpoint('/RecordDeployments/List'); +const getDeploymentApi = createApiEndpoint('/RecordDeployments/Get'); +const listConnectorsApi = createApiEndpoint('/RecordDeploymentConnectors/List'); +const getConnectorApi = createApiEndpoint('/RecordDeploymentConnectors/Get'); +const connectorRunsApi = createApiEndpoint('/RecordDeploymentConnectors/Runs'); +const reconciliationApi = createApiEndpoint('/RecordDeploymentConnectors/Reconciliation'); + +export const getRecordDeployments = async (includeClosed = false, signal?: AbortSignal) => { + const response = await listDeploymentsApi.get>({ includeClosed }, signal); + return response.data; +}; + +export const getRecordDeployment = async (id: string, signal?: AbortSignal) => { + const response = await getDeploymentApi.get>({ id }, signal); + return response.data; +}; + +export const getRecordDeploymentConnectors = async (signal?: AbortSignal) => { + const response = await listConnectorsApi.get>(undefined, signal); + return response.data; +}; + +export const getRecordDeploymentConnector = async (id: string, signal?: AbortSignal) => { + const response = await getConnectorApi.get>({ id }, signal); + return response.data; +}; + +export const getRecordDeploymentConnectorRuns = async (id: string, take = 50, signal?: AbortSignal) => { + const response = await connectorRunsApi.get>({ id, take }, signal); + return response.data; +}; + +export const getRecordDeploymentReconciliation = async (connectorId?: string | null, signal?: AbortSignal) => { + const response = await reconciliationApi.get>(connectorId ? { id: connectorId } : undefined, signal); + return response.data; +}; + +/** Reads the connector's feed now. The server binds the id from the query, so the endpoint carries it. */ +export const runRecordDeploymentConnector = async (id: string, signal?: AbortSignal) => { + const runApi = createApiEndpoint(`/RecordDeploymentConnectors/Run?id=${encodeURIComponent(id)}`); + const response = await runApi.post>({}, signal); + return response.data; +}; diff --git a/src/api/records/field-records.ts b/src/api/records/field-records.ts new file mode 100644 index 00000000..96cb21a5 --- /dev/null +++ b/src/api/records/field-records.ts @@ -0,0 +1,110 @@ +import { + type FieldRecordAssignmentData, + type FieldRecordCatalogData, + type FieldRecordCatalogInput, + type FieldRecordPrefillData, + type FieldRecordPreflightData, + type FieldRecordSyncData, + type RecordsApiResult, +} from '@/models/v4/records'; + +import { createApiEndpoint } from '../common/client'; + +// v4 FieldRecords (RMS plan RMS-1D). The server derives the department, the app flag, the verified +// context and the Protected Data state from the authenticated principal; these inputs only narrow +// the response, so nothing here is a capability the client grants itself. + +const preflightApi = createApiEndpoint('/FieldRecords/Preflight'); +const catalogApi = createApiEndpoint('/FieldRecords/Catalog'); +const prefillApi = createApiEndpoint('/FieldRecords/Prefill'); +const syncApi = createApiEndpoint('/FieldRecords/Sync'); +const assignmentsApi = createApiEndpoint('/FieldRecords/Assignments'); +const recordAssignmentsApi = createApiEndpoint('/FieldRecords/RecordAssignments'); +const acknowledgeAssignmentApi = createApiEndpoint('/FieldRecords/AcknowledgeAssignment'); +const telemetryApi = createApiEndpoint('/FieldRecords/Telemetry'); +const completeAssignmentApi = createApiEndpoint('/FieldRecords/CompleteAssignment'); + +export const getFieldRecordsPreflight = async (originClient: number, appVersion?: string | null, clientCapability?: string | null, signal?: AbortSignal) => { + const response = await preflightApi.get>( + { + originClient, + ...(appVersion ? { appVersion } : {}), + ...(clientCapability ? { clientCapability } : {}), + }, + signal + ); + return response.data; +}; + +export const getFieldRecordsCatalog = async (input: FieldRecordCatalogInput, signal?: AbortSignal) => { + const response = await catalogApi.post>(input as unknown as Record, signal); + return response.data; +}; + +export const getFieldRecordPrefill = async (input: FieldRecordCatalogInput & { DefinitionKey: string; Version: number }, signal?: AbortSignal) => { + const response = await prefillApi.post>(input as unknown as Record, signal); + return response.data; +}; + +export interface FieldRecordSyncInput extends FieldRecordCatalogInput { + Since?: number; + SinceId?: string | null; + ScopeStamp?: string | null; + Take?: number; + IncludeCatalog?: boolean; +} + +export const syncFieldRecords = async (input: FieldRecordSyncInput, signal?: AbortSignal) => { + const response = await syncApi.post>(input as unknown as Record, signal); + return response.data; +}; + +export const getFieldRecordAssignments = async (input: FieldRecordCatalogInput, signal?: AbortSignal) => { + const response = await assignmentsApi.post>(input as unknown as Record, signal); + return response.data; +}; + +export const getAssignmentsForRecord = async (recordId: string, signal?: AbortSignal) => { + const response = await recordAssignmentsApi.get>({ recordId }, signal); + return response.data; +}; + +export interface FieldRecordAssignmentCommandInput { + AssignmentId: string; + RowVersion?: number | null; + Reason?: string | null; + Context?: FieldRecordCatalogInput['Context']; + OriginClient?: number | null; +} + +export interface FieldRecordTelemetryBatch { + OriginClient: number; + AppVersion?: string | null; + ClientCapability?: string | null; + Events: { + EventType: string; + Outcome?: string; + DefinitionKey?: string; + DefinitionVersion?: number; + RecordId?: string; + DurationMs?: number; + ItemCount?: number; + OccurredOn?: string; + }[]; +} + +/** Bounded, coded rollout datapoints (RMS plan RMS-1D). Counts and outcomes only, never content. */ +export const reportFieldRecordTelemetry = async (batch: FieldRecordTelemetryBatch, signal?: AbortSignal) => { + const response = await telemetryApi.post>(batch as unknown as Record, signal); + return response.data; +}; + +export const acknowledgeAssignment = async (input: FieldRecordAssignmentCommandInput, signal?: AbortSignal) => { + const response = await acknowledgeAssignmentApi.post>(input as unknown as Record, signal); + return response.data; +}; + +export const completeAssignment = async (input: FieldRecordAssignmentCommandInput, signal?: AbortSignal) => { + const response = await completeAssignmentApi.post>(input as unknown as Record, signal); + return response.data; +}; diff --git a/src/api/records/record-uploads.ts b/src/api/records/record-uploads.ts new file mode 100644 index 00000000..14fabe1b --- /dev/null +++ b/src/api/records/record-uploads.ts @@ -0,0 +1,107 @@ +import { type RecordsApiResult } from '@/models/v4/records'; + +import { createApiEndpoint } from '../common/client'; + +// Resumable attachment uploads (RMS plan RMS-1D). The server owns the session: it declares the chunk +// size, remembers how many bytes it has, and assembles and hygiene-checks the file at the end. The +// client's job is to keep sending the next chunk from wherever the server says it got to. + +export interface RecordUploadData { + UploadId: string; + RecordId: string; + FileName: string; + ContentType: string; + DeclaredSize: number; + ReceivedBytes: number; + ChunkSize: number; + ChunkCount: number; + /** RecordUploadSessionState: 1 open, 2 completed, 3 aborted, 4 expired. */ + State: number; + StateName?: string | null; + ExpiresOn: string; + AttachmentId?: string | null; +} + +export interface RecordAttachmentData { + AttachmentId: string; + FileName?: string | null; + ContentType?: string | null; + ByteSize?: number; + Checksum?: string | null; + ScanState?: number; + /** True when EXIF, XMP and IPTC were removed on upload; images are always re-encoded. */ + MetadataStripped?: boolean; + /** True when the definition's profile asked for the photo's coordinates and they survived. */ + MediaLocationRetained?: boolean; + Classification?: number | null; + UploadedOn?: string | null; +} + +const beginUploadApi = createApiEndpoint('/Records/BeginUpload'); +const uploadChunkApi = createApiEndpoint('/Records/UploadChunk'); +const getUploadApi = createApiEndpoint('/Records/GetUpload'); +const completeUploadApi = createApiEndpoint('/Records/CompleteUpload'); +const abortUploadApi = createApiEndpoint('/Records/AbortUpload'); +const getAttachmentsApi = createApiEndpoint('/Records/GetAttachments'); +const removeAttachmentApi = createApiEndpoint('/Records/RemoveAttachment'); + +export interface BeginUploadInput { + RecordId: string; + FileName: string; + ContentType: string; + /** Total size of the file; the server declares the chunk size back. */ + ByteSize: number; + /** Lower-case hex SHA-256 of the whole file; the server refuses the assembly if it does not match. */ + Sha256: string; +} + +export const beginRecordUpload = async (input: BeginUploadInput, signal?: AbortSignal) => { + const response = await beginUploadApi.post>(input as unknown as Record, signal); + return response.data; +}; + +export interface UploadChunkInput { + UploadId: string; + /** Byte offset this chunk starts at; the server rejects anything but the offset it is waiting for. */ + Offset: number; + /** Base64 of the chunk's bytes. */ + Data: string; +} + +export const uploadRecordChunk = async (input: UploadChunkInput, signal?: AbortSignal) => { + const response = await uploadChunkApi.post>(input as unknown as Record, signal); + return response.data; +}; + +/** The server's view of a session; this is what makes an interrupted upload resumable. */ +export const getRecordUpload = async (uploadId: string, signal?: AbortSignal) => { + const response = await getUploadApi.get>({ uploadId }, signal); + return response.data; +}; + +export interface CompleteUploadInput { + UploadId: string; + Description?: string | null; + /** RmsEvidenceClassification; 1 is unrestricted. */ + Classification?: number | null; +} + +export const completeRecordUpload = async (input: CompleteUploadInput, signal?: AbortSignal) => { + const response = await completeUploadApi.post>(input as unknown as Record, signal); + return response.data; +}; + +export const abortRecordUpload = async (uploadId: string, signal?: AbortSignal) => { + const response = await abortUploadApi.post>({ UploadId: uploadId }, signal); + return response.data; +}; + +export const getRecordAttachments = async (recordId: string, signal?: AbortSignal) => { + const response = await getAttachmentsApi.get>({ id: recordId }, signal); + return response.data; +}; + +export const removeRecordAttachment = async (recordId: string, attachmentId: string, signal?: AbortSignal) => { + const response = await removeAttachmentApi.post>({ RecordId: recordId, AttachmentId: attachmentId }, signal); + return response.data; +}; diff --git a/src/api/records/records.ts b/src/api/records/records.ts new file mode 100644 index 00000000..3df9f8cc --- /dev/null +++ b/src/api/records/records.ts @@ -0,0 +1,50 @@ +import { type RecordCommandInput, type RecordCreateDraftInput, type RecordData, type RecordDefinitionVersionData, type RecordsApiResult, type RecordSaveDraftInput } from '@/models/v4/records'; + +import { createApiEndpoint } from '../common/client'; + +// v4 Records authoring, and the definition version the renderer draws from. Lifecycle actions a field +// client may issue are create, save, submit-for-review, finalize and cancel; amendment, void and +// approval stay Web-first, so they are deliberately absent here. + +const getRecordApi = createApiEndpoint('/Records/GetRecord'); +const createDraftApi = createApiEndpoint('/Records/CreateDraft'); +const saveDraftApi = createApiEndpoint('/Records/SaveDraft'); +const submitForReviewApi = createApiEndpoint('/Records/SubmitForReview'); +const finalizeApi = createApiEndpoint('/Records/Finalize'); +const cancelApi = createApiEndpoint('/Records/Cancel'); +const definitionVersionApi = createApiEndpoint('/RecordDefinitions/Version'); + +export const getRecord = async (id: string, signal?: AbortSignal) => { + const response = await getRecordApi.get>({ id }, signal); + return response.data; +}; + +export const getRecordDefinitionVersion = async (key: string, version: number, signal?: AbortSignal) => { + const response = await definitionVersionApi.get>({ key, version }, signal); + return response.data; +}; + +export const createRecordDraft = async (input: RecordCreateDraftInput, signal?: AbortSignal) => { + const response = await createDraftApi.post>(input as unknown as Record, signal); + return response.data; +}; + +export const saveRecordDraft = async (input: RecordSaveDraftInput, signal?: AbortSignal) => { + const response = await saveDraftApi.post>(input as unknown as Record, signal); + return response.data; +}; + +export const submitRecordForReview = async (input: RecordCommandInput, signal?: AbortSignal) => { + const response = await submitForReviewApi.post>(input as unknown as Record, signal); + return response.data; +}; + +export const finalizeRecord = async (input: RecordCommandInput, signal?: AbortSignal) => { + const response = await finalizeApi.post>(input as unknown as Record, signal); + return response.data; +}; + +export const cancelRecord = async (input: RecordCommandInput, signal?: AbortSignal) => { + const response = await cancelApi.post>(input as unknown as Record, signal); + return response.data; +}; diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 637708ac..ce442756 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -626,6 +626,10 @@ export default function TabLayout() { + + + + diff --git a/src/app/(app)/checklists.tsx b/src/app/(app)/checklists.tsx new file mode 100644 index 00000000..bdc9ceb9 --- /dev/null +++ b/src/app/(app)/checklists.tsx @@ -0,0 +1,148 @@ +import { Stack, useFocusEffect, useLocalSearchParams } from 'expo-router'; +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView, View } from 'react-native'; + +import { ChecklistCalendar } from '@/components/checklists/checklist-calendar'; +import { ChecklistRunSheet } from '@/components/checklists/checklist-run-sheet'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useChecklistLiveUpdates } from '@/hooks/use-checklist-live-updates'; +import { useProtectedReveal } from '@/hooks/use-protected-reveal'; +import { checklistError } from '@/lib/checklists/sync'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useChecklistsStore } from '@/stores/checklists/store'; + +export default function ChecklistsScreen() { + const { t } = useTranslation(); + const { occurrenceId } = useLocalSearchParams<{ occurrenceId?: string }>(); + const activeUnit = useCoreStore((s) => s.activeUnitId); + const unitId = activeUnit || undefined; + const state = useChecklistsStore(); + const [page, setPage] = useState(0); + const [calendar, setCalendar] = useState(false); + const [historyPage, setHistoryPage] = useState(0); + const [error, setError] = useState(null); + const load = useCallback(() => { + void useChecklistsStore.getState().load(unitId, page); + }, [unitId, page]); + useChecklistLiveUpdates(load); + const reveal = useProtectedReveal(load); + useFocusEffect( + useCallback(() => { + let cancelled = false; + void (async () => { + await useChecklistsStore.getState().load(unitId, page); + if (!cancelled && occurrenceId && !useChecklistsStore.getState().locked) await useChecklistsStore.getState().openOccurrence(occurrenceId); + })().catch((e) => setError(checklistError(e))); + return () => { + cancelled = true; + useChecklistsStore.getState().close(); + }; + }, [unitId, page, occurrenceId]) + ); + const action = (work: () => Promise) => { + setError(null); + void work().catch((e) => setError(checklistError(e))); + }; + const message = error ?? state.error; + return ( + + + + + {calendar ? : null} + {t('checklists.labels.DueChecks')} + {message ? {t(`checklists.errors.${message}`)} : null} + {state.locked && state.access?.IsProtected ? ( + + ) : null} + + {state.due ? ( + + ) : null} + {state.due?.Occurrences.map((row) => ( + + {row.Name} + + {row.Target.Name} · {new Date(row.PeriodStartUtc).toLocaleString()} + + + + ))} + {state.due && state.due.Occurrences.length === 0 ? {t('checklists.labels.NoDueChecks')} : null} + {t('checklists.labels.StartRun')} + {state.due?.Definitions.map((def) => ( + + {def.PublishedForm.Name} + {def.Targets.map((target) => ( + + ))} + + ))} + + + + + {t('checklists.ui.localDrafts')} + {state.draftIds.map((id, index) => ( + + ))} + + {state.history.map((row) => ( + + ))} + {historyPage > 0 ? ( + + ) : null} + {state.hasMoreHistory ? ( + + ) : null} + + {state.active ? : null} + + ); +} diff --git a/src/app/(app)/inventory/_layout.tsx b/src/app/(app)/inventory/_layout.tsx new file mode 100644 index 00000000..44464569 --- /dev/null +++ b/src/app/(app)/inventory/_layout.tsx @@ -0,0 +1,6 @@ +import { Stack } from 'expo-router'; +import React from 'react'; + +export default function InventoryLayout() { + return ; +} diff --git a/src/app/(app)/inventory/count/[id].tsx b/src/app/(app)/inventory/count/[id].tsx new file mode 100644 index 00000000..8dea6127 --- /dev/null +++ b/src/app/(app)/inventory/count/[id].tsx @@ -0,0 +1,175 @@ +import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; +import { ArrowLeft, Minus, Plus } from 'lucide-react-native'; +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert, ScrollView } from 'react-native'; + +import { Button, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField } from '@/components/ui/input'; +import { Pressable } from '@/components/ui/pressable'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { clampQuantity, contentOf, isComplete, locationNames, observedQuantity, orderLocations, variances } from '@/lib/inventory/count'; +import type { InventoryCountLine } from '@/models/v4/inventory'; +import { InventoryCountStatus } from '@/models/v4/inventory'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useInventoryStore } from '@/stores/inventory/store'; + +// One inventory count of the unit: every stock position and serialized item the unit is expected to carry, +// compartment by compartment. The crew enters what is actually there; completing posts the differences to +// the ledger (a missing serialized item becomes Lost). Controlled items wait for a witness on the web. +export default function InventoryCountScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { id } = useLocalSearchParams<{ id: string }>(); + const activeUnit = useCoreStore((state) => state.activeUnit); + const { access, count, observed, busy, error, awaitingWitness } = useInventoryStore(); + + useFocusEffect( + useCallback(() => { + if (id && useInventoryStore.getState().count?.Count.Id !== id) void useInventoryStore.getState().open(id); + return undefined; + }, [id]) + ); + + const names = useMemo(() => locationNames(access?.UnitLocations ?? [], t('inventory.unnamedLocation')), [access, t]); + const order = useMemo(() => orderLocations(access?.UnitLocations ?? []).map((location) => location.Id), [access]); + const grouped = useMemo(() => { + const groups: Record = {}; + (count?.Lines ?? []).forEach((line) => (groups[line.LocationId] ??= []).push(line)); + Object.values(groups).forEach((lines) => lines.sort((a, b) => (contentOf(a.Content).ItemName ?? '').localeCompare(contentOf(b.Content).ItemName ?? ''))); + return Object.entries(groups).sort(([a], [b]) => (order.indexOf(a) === -1 ? 999 : order.indexOf(a)) - (order.indexOf(b) === -1 ? 999 : order.indexOf(b))); + }, [count, order]); + + const locationLabel = (locationId: string) => { + const location = access?.UnitLocations.find((candidate) => candidate.Id === locationId); + return location?.IsRoot ? activeUnit?.Name || names[locationId] : (names[locationId] ?? t('inventory.unnamedLocation')); + }; + const lines = count?.Lines ?? []; + const editable = count?.Count.Status === InventoryCountStatus.Draft; + const counted = lines.filter((line) => observedQuantity(line, observed) != null).length; + const differences = variances(lines, observed); + const set = (line: InventoryCountLine, value: number) => useInventoryStore.getState().setObserved(line.Id, clampQuantity(line, value)); + + const complete = () => + Alert.alert(t('inventory.completeTitle'), differences.length > 0 ? t('inventory.completeBody', { count: differences.length }) : t('inventory.completeNoChanges'), [ + { text: t('inventory.cancel'), style: 'cancel' }, + { text: t('inventory.complete'), onPress: () => void useInventoryStore.getState().complete() }, + ]); + + const cancel = () => + Alert.alert(t('inventory.cancelTitle'), t('inventory.cancelBody'), [ + { text: t('inventory.keep'), style: 'cancel' }, + { + text: t('inventory.cancelCount'), + style: 'destructive', + onPress: async () => { + if (await useInventoryStore.getState().cancel()) router.back(); + }, + }, + ]); + + return ( + + + + + router.back()} testID="inventory-count-back" accessibilityRole="button" accessibilityLabel={t('inventory.back')}> + + + {contentOf(count?.Count.Content).Name || t('inventory.count')} + + {busy && !count ? : null} + {error ? ( + + {t(`inventory.errors.${error}`, { defaultValue: t('inventory.errors.retry') })} + + ) : null} + {count ? ( + + {t(`inventory.countStatus.${count.Count.Status}`)} · {t('inventory.progress', { counted, total: lines.length })} · {t('inventory.differences', { count: differences.length })} + + ) : null} + {awaitingWitness ? {t('inventory.awaitingWitness')} : null} + {count?.Count.Status === InventoryCountStatus.Completed ? {t('inventory.completed')} : null} + + {grouped.map(([locationId, rows]) => ( + + {locationLabel(locationId)} + {rows.map((line) => { + const details = contentOf(line.Content); + const value = observedQuantity(line, observed); + const differs = value != null && value !== line.ExpectedQuantity; + return ( + + {details.ItemName || t('inventory.unnamedItem')} + {details.SerialNumber || details.LotNumber ? {[details.SerialNumber, details.LotNumber].filter(Boolean).join(' · ')} : null} + {t('inventory.expected', { quantity: line.ExpectedQuantity, unit: details.UnitOfMeasure ?? '' })} + {line.AssetId ? ( + + + + + ) : ( + + set(line, (value ?? line.ExpectedQuantity) - 1)} + disabled={!editable} + accessibilityRole="button" + accessibilityLabel={t('inventory.less')} + testID={`inventory-less-${line.Id}`} + > + + + + (text.trim() === '' ? undefined : set(line, Number(text.replace(',', '.'))))} + testID={`inventory-quantity-${line.Id}`} + /> + + set(line, (value ?? line.ExpectedQuantity) + 1)} + disabled={!editable} + accessibilityRole="button" + accessibilityLabel={t('inventory.more')} + testID={`inventory-more-${line.Id}`} + > + + + + + )} + + ); + })} + + ))} + + {editable ? ( + + + + + + ) : null} + + + ); +} diff --git a/src/app/(app)/inventory/index.tsx b/src/app/(app)/inventory/index.tsx new file mode 100644 index 00000000..58f08f0d --- /dev/null +++ b/src/app/(app)/inventory/index.tsx @@ -0,0 +1,128 @@ +import { type Href, Stack, useFocusEffect, useRouter } from 'expo-router'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshControl, ScrollView } from 'react-native'; + +import { OptionSelect } from '@/components/operations/option-select'; +import { Button, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { contentOf, groupEquipment, locationNames, orderLocations } from '@/lib/inventory/count'; +import { InventoryAssetStatus, InventoryCountStatus } from '@/models/v4/inventory'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useInventoryStore } from '@/stores/inventory/store'; + +const todayKey = () => { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; +}; + +// What the active unit carries, compartment by compartment, and the inventory counts the crew runs against +// it. Starting a count needs Adjust Inventory at the unit (a department permission); viewing does not. +export default function UnitInventoryScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const activeUnitId = useCoreStore((state) => state.activeUnitId); + const activeUnit = useCoreStore((state) => state.activeUnit); + const { access, equipment, counts, busy, error } = useInventoryStore(); + const [countLocation, setCountLocation] = useState(''); + const unitId = activeUnitId ? Number(activeUnitId) : null; + + useFocusEffect( + useCallback(() => { + if (unitId) void useInventoryStore.getState().load(unitId); + }, [unitId]) + ); + + const locations = useMemo(() => orderLocations(access?.UnitLocations ?? []), [access]); + const names = useMemo(() => locationNames(locations, t('inventory.unnamedLocation')), [locations, t]); + const groups = useMemo(() => groupEquipment(equipment, t('inventory.unnamedItem')), [equipment, t]); + const root = locations.find((location) => location.IsRoot); + const selected = countLocation || root?.Id || ''; + const writable = !!access?.Enabled && !!access.Migrated; + + const start = async () => { + if (!selected) return; + const label = selected === root?.Id ? activeUnit?.Name || names[selected] : names[selected]; + const id = await useInventoryStore.getState().start(selected, t('inventory.countName', { name: label, date: todayKey() })); + if (id) router.push(`/inventory/count/${id}` as Href); + }; + + return ( + + + unitId && void useInventoryStore.getState().load(unitId)} />}> + {activeUnit?.Name ? t('inventory.titleFor', { name: activeUnit.Name }) : t('inventory.title')} + {!unitId ? {t('inventory.selectUnit')} : null} + {busy && !access ? : null} + {error ? ( + + {t(`inventory.errors.${error}`, { defaultValue: t('inventory.errors.retry') })} + + ) : null} + {access && !writable ? {access.Enabled ? t('inventory.notMigrated') : t('inventory.disabled')} : null} + {access && locations.length === 0 ? {t('inventory.noLocations')} : null} + + {access?.CanCount && writable && locations.length > 0 ? ( + + {t('inventory.startCount')} + {locations.length > 1 ? ( + ({ value: location.Id, label: location.IsRoot ? t('inventory.wholeUnit', { name: activeUnit?.Name || names[location.Id] }) : names[location.Id] }))} + placeholder={t('inventory.pickLocation')} + onChange={setCountLocation} + testID="inventory-count-location" + /> + ) : null} + {t('inventory.startHelp')} + + + ) : null} + {access && !access.CanCount && locations.length > 0 ? {t('inventory.cannotCount')} : null} + + {counts.length > 0 ? ( + + {t('inventory.counts')} + {counts.slice(0, 10).map((count) => ( + router.push(`/inventory/count/${count.Id}` as Href)} testID={`inventory-count-${count.Id}`} className="rounded-lg border border-outline-200 p-2"> + + {contentOf(count.Content).Name || t('inventory.count')} + {t(`inventory.countStatus.${count.Status}`)} + + + {String(count.CompletedOn ?? count.SnapshotOn ?? '') + .slice(0, 16) + .replace('T', ' ')} + + + ))} + + ) : null} + + {locations.map((location) => ( + + {location.IsRoot ? activeUnit?.Name || names[location.Id] : names[location.Id]} + {(groups[location.Id] ?? []).length === 0 ? {t('inventory.empty')} : null} + {(groups[location.Id] ?? []).map((row) => ( + + + {row.name} + {row.serial ? {t('inventory.serial', { serial: row.serial })} : null} + + + {row.assetStatus != null ? t(`inventory.assetStatus.${row.assetStatus}`) : t('inventory.quantity', { quantity: row.quantity })} + + + ))} + + ))} + + + ); +} diff --git a/src/app/(app)/operations/[id].tsx b/src/app/(app)/operations/[id].tsx new file mode 100644 index 00000000..2cc98d9c --- /dev/null +++ b/src/app/(app)/operations/[id].tsx @@ -0,0 +1,258 @@ +import { Redirect, Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; +import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react-native'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView } from 'react-native'; + +import { ExpensesPanel } from '@/components/operations/expenses-panel'; +import { MarsPanel } from '@/components/operations/mars-panel'; +import { AwaitingApproval, DayReports, ScopePicker, useScopeLabel } from '@/components/operations/scope-picker'; +import { TimeReportEditor } from '@/components/operations/time-report-editor'; +import { UsageForm } from '@/components/operations/usage-form'; +import { Button, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { operationsCapabilities } from '@/lib/operations/capabilities'; +import { + availableScopes, + canWriteSubject, + coveringReport, + dateOf, + defaultScope, + localDateKey, + reportScope, + reportsOnDate, + scopeKey, + scopeName, + scopeSubjects, + shiftDateKey, + subjectNames, + type TimeScope, +} from '@/lib/operations/time'; +import { DeploymentFinanceMode, TimeReportStatus } from '@/models/v4/operations'; +import useAuthStore from '@/stores/auth/store'; +import { useDeploymentsStatus } from '@/stores/feature-flags/store'; +import { useOperationsStore } from '@/stores/operations/store'; + +type Section = 'time' | 'expenses' | 'usage' | 'mars'; + +// One deployment: the crew or individual time report for a chosen day, expenses, resource usage readings and +// the MARS F-42 state. What the person may open and write comes from the server's TimeAccess (their roster row, +// the deployed units they crew) and this app's capabilities; approvers get the submitted queue. +export default function OperationsDeploymentScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { id } = useLocalSearchParams<{ id: string }>(); + const flagStatus = useDeploymentsStatus(); + const userId = useAuthStore((state) => state.userId); + const { costAccess, marsAccess, deployment, reports, scope, report, entries, dirty, issues, warnings, expenses, usage, marsItems, validation, busy, error } = useOperationsStore(); + const [dateKey, setDateKey] = useState(localDateKey()); + const dateRef = useRef(dateKey); + const [section, setSection] = useState
('time'); + const activeUnitId = operationsCapabilities.useActiveUnitId(); + const activeUnitRef = useRef(activeUnitId); + activeUnitRef.current = activeUnitId; + const scopeLabel = useScopeLabel(); + + useFocusEffect( + useCallback(() => { + if (flagStatus !== 'enabled' || !id) return undefined; + void (async () => { + const store = useOperationsStore.getState(); + if (!store.access) await store.loadAccess(); + if (!useOperationsStore.getState().access?.Enabled) return; + await useOperationsStore.getState().open(id); + const opened = useOperationsStore.getState().deployment; + if (opened) useOperationsStore.getState().setScope(defaultScope(opened, activeUnitRef.current), dateRef.current); + await Promise.all([useOperationsStore.getState().loadExpenses(), useOperationsStore.getState().loadUsage(), useOperationsStore.getState().loadMars()]); + })(); + return () => useOperationsStore.getState().close(); + }, [flagStatus, id]) + ); + + const access = deployment?.TimeAccess ?? null; + const scopes = useMemo(() => (deployment ? availableScopes(deployment) : []), [deployment]); + const names = useMemo(() => (deployment ? subjectNames(deployment) : {}), [deployment]); + // The picker offers the subjects of the open report's own scope (a manager may open any report on the day). + const openScope: TimeScope | null = report ? reportScope(report) : scope; + const subjects = useMemo(() => (deployment ? scopeSubjects(deployment, openScope) : []), [deployment, openScope]); + const dayReports = useMemo(() => reportsOnDate(reports, dateKey), [reports, dateKey]); + const awaiting = useMemo(() => reports.filter((candidate) => candidate.Status === TimeReportStatus.Submitted).sort((a, b) => a.ReportDate.localeCompare(b.ReportDate)), [reports]); + const canApprove = operationsCapabilities.approveTime && !!access?.CanApprove; + const supervises = !!access?.CanManage || canApprove; + const covered = useMemo(() => { + if (report || !scope) return null; + const subject = scope.kind === 'crew' ? scope.unitId : scope.kind === 'individual' ? scope.personnelId : null; + return subject ? coveringReport(reports, dateKey, subject) : null; + }, [report, scope, reports, dateKey]); + const canCreate = operationsCapabilities.editTime && !!scope && (scope.kind !== 'deployment' || !!access?.CanManage); + const writable = useCallback((subjectId: string) => !!deployment && canWriteSubject(deployment, subjectId), [deployment]); + const showUsage = operationsCapabilities.recordUsage && !!costAccess?.Enabled && !!costAccess.CanRecordUsage; + const showExpenses = operationsCapabilities.recordExpenses && (access?.CanManage || (access?.WritableSubjectIds.length ?? 0) > 0); + const showMars = !!marsAccess?.Enabled && deployment?.FinanceMode === DeploymentFinanceMode.CostRecovery; + const canDraftF42 = operationsCapabilities.draftF42 && showMars && (!!marsAccess?.CanManage || (access?.WritableSubjectIds.length ?? 0) > 0); + const usageUnits = useMemo(() => { + if (!deployment) return []; + const units = deployment.Units.filter((unit) => unit.IsActive); + if (access?.CanManage) return units; + const crewed = units.filter((unit) => access?.CrewUnitIds.includes(unit.Id)); + const mine = activeUnitId ? crewed.filter((unit) => String(unit.UnitId) === activeUnitId) : []; + return mine.length > 0 ? mine : crewed; + }, [deployment, access, activeUnitId]); + + const changeDay = (days: number) => { + const next = shiftDateKey(dateKey, days); + dateRef.current = next; + setDateKey(next); + useOperationsStore.getState().setScope(useOperationsStore.getState().scope, next); + }; + + const openReport = (target: { Id: string; ReportDate: string }) => { + const day = dateOf(target.ReportDate); + dateRef.current = day; + setDateKey(day); + setSection('time'); + useOperationsStore.getState().selectReport(target.Id); + }; + + if (flagStatus === 'disabled') return ; + + return ( + + + + + router.back()} testID="operations-back" accessibilityRole="button" accessibilityLabel={t('operations.back')}> + + + {deployment?.Name ?? t('operations.title')} + + {flagStatus === 'unknown' || (busy && !deployment) ? : null} + {error ? ( + + {t(`operations.errors.${error}`, { defaultValue: t('operations.errors.retry') })} + + ) : null} + {deployment ? ( + + + {t(`operations.status.${deployment.Status}`)} · {t(`operations.financeMode.${deployment.FinanceMode}`)} + + + {[deployment.IncidentNumber, deployment.RequestNumber, deployment.ResourceOrderNumber, deployment.CostCode].filter(Boolean).join(' · ') || t('operations.noNumbers')} + + + {t('operations.roster', { units: deployment.Units.filter((unit) => unit.IsActive).length, personnel: deployment.Personnel.filter((person) => person.IsActive).length })} + + {access && access.CrewUnitIds.length > 0 ? ( + + {t('operations.crewOf', { + names: access.CrewUnitIds.map((unitId) => scopeName(deployment, { kind: 'crew', unitId })) + .filter(Boolean) + .join(', '), + })} + + ) : null} + + ) : null} + {deployment ? ( + + + {showExpenses ? ( + + ) : null} + {showUsage ? ( + + ) : null} + {showMars ? ( + + ) : null} + + ) : null} + {deployment && section !== 'mars' ? ( + + changeDay(-1)} testID="operations-day-previous" accessibilityRole="button" accessibilityLabel={t('operations.previousDay')}> + + + {dateKey} + changeDay(1)} testID="operations-day-next" accessibilityRole="button" accessibilityLabel={t('operations.nextDay')}> + + + + ) : null} + {deployment && section === 'time' ? ( + + {canApprove ? : null} + useOperationsStore.getState().setScope(next, dateKey)} /> + {supervises ? : null} + {scopes.length === 0 && !supervises ? {t('operations.time.notOnRoster')} : null} + {scope || report ? ( + void useOperationsStore.getState().openReport(dateKey, true)} + onChange={(next) => useOperationsStore.getState().setEntries(next)} + onSave={() => void useOperationsStore.getState().save()} + onSubmit={() => void useOperationsStore.getState().submit()} + onSign={(crewBoss, customer) => void useOperationsStore.getState().sign(crewBoss, customer)} + onApprove={() => report && void useOperationsStore.getState().approve(report.Id)} + /> + ) : null} + + ) : null} + {deployment && section === 'expenses' && showExpenses ? ( + useOperationsStore.getState().addExpense(input)} + onRemove={(expenseId) => useOperationsStore.getState().removeExpense(expenseId)} + /> + ) : null} + {deployment && section === 'usage' && showUsage ? ( + useOperationsStore.getState().addUsage(input)} /> + ) : null} + {deployment && section === 'mars' && showMars ? ( + void useOperationsStore.getState().draftF42()} + onValidate={(workItemId) => void useOperationsStore.getState().validate(workItemId)} + /> + ) : null} + + + ); +} diff --git a/src/app/(app)/operations/_layout.tsx b/src/app/(app)/operations/_layout.tsx new file mode 100644 index 00000000..ed65a497 --- /dev/null +++ b/src/app/(app)/operations/_layout.tsx @@ -0,0 +1,6 @@ +import { Stack } from 'expo-router'; +import React from 'react'; + +export default function OperationsLayout() { + return ; +} diff --git a/src/app/(app)/operations/index.tsx b/src/app/(app)/operations/index.tsx new file mode 100644 index 00000000..41ed1d34 --- /dev/null +++ b/src/app/(app)/operations/index.tsx @@ -0,0 +1,77 @@ +import { type Href, Redirect, Stack, useFocusEffect, useRouter } from 'expo-router'; +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshControl, ScrollView } from 'react-native'; + +import { Button, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { operationsCapabilities } from '@/lib/operations/capabilities'; +import { useDeploymentsStatus } from '@/stores/feature-flags/store'; +import { useOperationsStore } from '@/stores/operations/store'; + +// Deployments the person may see (Workforce & Business Operations plan, Phase C): a rostered member's +// own, a manager's whole department. Gated by Operations.Deployments; unknown stays a spinner so a deep +// link never flashes the list before the flag resolves. +export default function OperationsScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const flagStatus = useDeploymentsStatus(); + const { access, deployments, includeClosed, busy, error } = useOperationsStore(); + + const load = useCallback(async () => { + await useOperationsStore.getState().loadAccess(); + if (useOperationsStore.getState().access?.Enabled) await useOperationsStore.getState().loadDeployments(); + }, []); + + useFocusEffect( + useCallback(() => { + if (flagStatus === 'enabled') void load(); + }, [flagStatus, load]) + ); + + if (flagStatus === 'disabled') return ; + + return ( + + + void load()} />}> + {t('operations.title')} + {flagStatus === 'unknown' ? : null} + {error ? ( + + {t(`operations.errors.${error}`, { defaultValue: t('operations.errors.retry') })} + + ) : null} + {access && !access.Enabled ? {t('operations.disabled')} : null} + {access?.Enabled ? ( + + + + + ) : null} + {access?.Enabled && !busy && deployments.length === 0 ? {t('operations.none')} : null} + {deployments.map((deployment) => ( + router.push(`/operations/${deployment.Id}` as Href)} testID={`operations-deployment-${deployment.Id}`} className="rounded-lg border border-outline-200 p-3"> + + {deployment.Name} + {t(`operations.status.${deployment.Status}`)} + + {[deployment.IncidentNumber, deployment.RequestNumber, deployment.ResourceOrderNumber].filter(Boolean).join(' · ') || t('operations.noNumbers')} + + {String(deployment.StartOn ?? '').slice(0, 10)} + {deployment.EndOn ? ` – ${String(deployment.EndOn).slice(0, 10)}` : ''} + + + ))} + + + ); +} diff --git a/src/app/(app)/records.tsx b/src/app/(app)/records.tsx new file mode 100644 index 00000000..c80a919e --- /dev/null +++ b/src/app/(app)/records.tsx @@ -0,0 +1,209 @@ +import { Redirect, Stack, useFocusEffect, useRouter } from 'expo-router'; +import { GlobeIcon, Plus, RadioTower } from 'lucide-react-native'; +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshControl, ScrollView } from 'react-native'; + +import { RecordsEntryRow } from '@/components/records/deployment-items'; +import { AssignmentItem, PendingDraftItem, RecordListItem } from '@/components/records/record-list-item'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Divider } from '@/components/ui/divider'; +import { Fab, FabIcon } from '@/components/ui/fab'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { useRecordsContext } from '@/hooks/use-records-context'; +import { RmsRecordState } from '@/models/v4/records'; +import { useRecordsFieldStatus } from '@/stores/feature-flags/store'; +import { useDeploymentsStore, useOpenDeploymentCount, useReconciliationCount } from '@/stores/records/deployments-store'; +import { useRecordsStore } from '@/stores/records/store'; +import { securityStore } from '@/stores/security/store'; + +// Field Records home (RMS plan RMS-1D): New, Assigned, Drafts, Returned and Recent. Everything shown +// here came from the server's own filtering; this screen never decides what the person may author. + +// Module-level so a re-render reuses the same component type; defining these inside the screen would +// remount every section on each keystroke and throw away their scroll position. +const Section = ({ title, children }: { title: string; children: React.ReactNode }) => ( + + + {title} + + {children} + + +); + +const Empty = ({ text }: { text: string }) => {text}; + +export default function RecordsScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const flagStatus = useRecordsFieldStatus(); + const context = useRecordsContext(); + + const { preflight, catalog, assignments, recent, drafts, pendingDrafts, isSyncing, error } = useRecordsStore(); + const setContext = useRecordsStore((state) => state.setContext); + const runPreflight = useRecordsStore((state) => state.runPreflight); + const fetchCatalog = useRecordsStore((state) => state.fetchCatalog); + const sync = useRecordsStore((state) => state.sync); + const pushAllDrafts = useRecordsStore((state) => state.pushAllDrafts); + const runUploads = useRecordsStore((state) => state.runUploads); + const flushTelemetry = useRecordsStore((state) => state.flushTelemetry); + + // Deployments (RMS plan section 4.1) ride along on the same focus: the list is small and read-only, + // and the reconciliation count is the one number an administrator wants to see without opening it. + const isAdmin = !!securityStore((state) => state.rights)?.IsAdmin; + const openDeployments = useOpenDeploymentCount(); + const reconciliationCount = useReconciliationCount(); + const fetchDeployments = useDeploymentsStore((state) => state.fetchDeployments); + const fetchReconciliation = useDeploymentsStore((state) => state.fetchReconciliation); + + useFocusEffect( + useCallback(() => { + setContext(context); + void (async () => { + const result = await runPreflight(); + if (!result?.Ok) { + return; + } + await fetchCatalog(); + await sync(); + await pushAllDrafts(); + await runUploads(); + await flushTelemetry(); + void fetchDeployments(); + if (isAdmin) { + void fetchReconciliation(); + } + })(); + }, [context, setContext, runPreflight, fetchCatalog, sync, pushAllDrafts, runUploads, flushTelemetry, fetchDeployments, fetchReconciliation, isAdmin]) + ); + + const pending = useMemo(() => Object.values(pendingDrafts), [pendingDrafts]); + const returned = useMemo(() => drafts.filter((record) => record.State === RmsRecordState.Returned), [drafts]); + const ownDrafts = useMemo(() => drafts.filter((record) => record.State === RmsRecordState.Draft), [drafts]); + const canCreate = (catalog?.Definitions?.length ?? 0) > 0; + + // The flag is authoritative and resolves fail-closed; an unknown state waits rather than redirecting. + if (flagStatus === 'disabled') { + return ; + } + + return ( + + + + + void sync()} />}> + {flagStatus === 'unknown' ? ( + + + {t('records.loading')} + + ) : null} + + {preflight && !preflight.Ok ? ( + + {t('records.unavailable')} + {(preflight.Reasons ?? []).map((reason) => ( + + {t(`records.reason_${reason}`, { defaultValue: reason })} + + ))} + + ) : null} + + {error ? ( + + {error} + + ) : null} + +
+ {assignments.length === 0 ? ( + + ) : ( + assignments.map((assignment) => ( + router.push(`/records/${assignment.RecordId}`)} testID={`records-assignment-${assignment.AssignmentId}`} /> + )) + )} +
+ +
+ } + title={t('records.deployments')} + subtitle={t('records.deployments_home_hint')} + count={openDeployments} + onPress={() => router.push('/records/deployments')} + testID="records-deployments" + /> + {isAdmin ? ( + } + title={t('records.connectors')} + subtitle={t('records.connectors_home_hint')} + count={reconciliationCount} + attention + onPress={() => router.push('/records/connectors')} + testID="records-connectors" + /> + ) : null} +
+ +
+ {pending.length === 0 && ownDrafts.length === 0 ? ( + + ) : ( + <> + {pending.map((draft) => ( + router.push(`/records/new?draft=${draft.clientRecordId}`)} testID={`records-pending-${draft.clientRecordId}`} /> + ))} + {ownDrafts.map((record) => ( + router.push(`/records/${record.RecordId}`)} testID={`records-draft-${record.RecordId}`} /> + ))} + + )} +
+ +
+ {returned.length === 0 ? ( + + ) : ( + returned.map((record) => router.push(`/records/${record.RecordId}`)} testID={`records-returned-${record.RecordId}`} />) + )} +
+ +
+ {recent.length === 0 ? ( + + ) : ( + recent.slice(0, 25).map((record) => router.push(`/records/${record.RecordId}`)} />) + )} +
+ + {!canCreate && preflight?.Ok ? ( + + {t('records.nothing_to_create')} + + + ) : null} + + +
+ + {canCreate ? ( + router.push('/records/new')} testID="records-new-fab"> + + + ) : null} +
+ ); +} diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index a5162d39..dc971d05 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -1,10 +1,28 @@ import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; -import { ClipboardListIcon, ClockIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, MapPinIcon, NavigationIcon, PaperclipIcon, RouteIcon, TimerIcon, UserIcon, UsersIcon, VideoIcon } from 'lucide-react-native'; +import { + BuildingIcon, + ClipboardListIcon, + ClockIcon, + FileTextIcon, + ImageIcon, + InfoIcon, + LoaderIcon, + MapPinIcon, + NavigationIcon, + PaperclipIcon, + RouteIcon, + TimerIcon, + UserIcon, + UsersIcon, + VideoIcon, +} from 'lucide-react-native'; import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; import { VideoFeedTabContent } from '@/components/call-video-feeds/video-feed-tab-content'; +import { ActivityLinkMarker } from '@/components/calls/activity-link-marker'; +import { CallSiteInfoTabPanel } from '@/components/calls/call-site-info-tab-panel'; import { CheckInTabContent } from '@/components/check-in-timers/check-in-tab-content'; import { HeaderBackButton } from '@/components/common/header-back-button'; import { Loading } from '@/components/common/loading'; @@ -15,6 +33,7 @@ import { IncidentCommandTabPanel } from '@/components/incident-command/incident- import { FullScreenMap } from '@/components/maps/full-screen-map'; // Import a static map component instead of react-native-maps import StaticMap from '@/components/maps/static-map'; +import { RecordsQuickCreate } from '@/components/records/records-quick-create'; import { FocusAwareStatusBar, SafeAreaView } from '@/components/ui'; import { Box } from '@/components/ui/box'; import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; @@ -25,6 +44,7 @@ import { SharedTabs, type TabItem } from '@/components/ui/shared-tabs'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; +import { useRecordsContext } from '@/hooks/use-records-context'; import { getUnitTypeCheckInBadge } from '@/lib/check-in-timer-utils'; import { isFieldRedacted, ProtectedFieldIds } from '@/lib/data-protection/redacted'; import { logger } from '@/lib/logging'; @@ -60,6 +80,8 @@ export default function CallDetail() { longitude: null, }); const call = useCallDetailStore((state) => state.call); + // Records context for this screen: the apparatus this device is signed on to, plus this Call. + const recordsContext = useRecordsContext(); const callExtraData = useCallDetailStore((state) => state.callExtraData); const callPriority = useCallDetailStore((state) => state.callPriority); const isLoading = useCallDetailStore((state) => state.isLoading); @@ -385,6 +407,8 @@ export default function CallDetail() { {call.DestinationTypeName || call.DestinationAddress ? {[call.DestinationTypeName, call.DestinationAddress].filter(Boolean).join(' - ')} : null} ) : null} + {/* Contextual create: the button hides itself unless the server offers something here. */} + {t('call_detail.note')} @@ -494,9 +518,13 @@ export default function CallDetail() { {callExtraData.Activity.map((event, index) => ( - - {event.StatusText} - + + + {event.StatusText} + + {/* Marks a status the sender did not link to this call themselves (auto-linked / inferred). */} + + {event.Name} - {event.Group} @@ -529,6 +557,14 @@ export default function CallDetail() { content: , }); + // Site Info tab: pre-plans, hazards, alert notes and files of the contacts linked to the call. + tabs.push({ + key: 'site', + title: t('call_detail.tabs.site'), + icon: , + content: , + }); + // Conditionally add check-in tab if (call?.CheckInTimersEnabled) { const checkInBadge = getUnitTypeCheckInBadge(timerStatuses, { diff --git a/src/app/call/__tests__/[id].security.test.tsx b/src/app/call/__tests__/[id].security.test.tsx index 8ea0d3c2..bb595b0f 100644 --- a/src/app/call/__tests__/[id].security.test.tsx +++ b/src/app/call/__tests__/[id].security.test.tsx @@ -219,6 +219,10 @@ jest.mock('@/components/call-video-feeds/video-feed-tab-content', () => ({ VideoFeedTabContent: () => null, })); +jest.mock('@/components/calls/call-site-info-tab-panel', () => ({ + CallSiteInfoTabPanel: () => null, +})); + jest.mock('@/stores/check-in-timers/store', () => ({ useCheckInTimerStore: jest.fn((selector: any) => selector({ diff --git a/src/app/call/__tests__/[id].test.tsx b/src/app/call/__tests__/[id].test.tsx index e2ca35ab..1c567b29 100644 --- a/src/app/call/__tests__/[id].test.tsx +++ b/src/app/call/__tests__/[id].test.tsx @@ -61,14 +61,28 @@ jest.mock('@/components/ui/hstack', () => ({ HStack: jest.fn().mockImplementation(({ children }) => children), })); +// Key of the tab the SharedTabs mock renders; undefined renders the first tab. +let mockSelectedTabKey: string | undefined; + jest.mock('@/components/ui/shared-tabs', () => ({ SharedTabs: jest.fn().mockImplementation(({ tabs }) => { const React = require('react'); const { View } = require('react-native'); - // Render the first tab's content by default - const firstTabContent = tabs && tabs.length > 0 ? tabs[0].content : null; - return React.createElement(View, {}, firstTabContent); + // Render the selected tab's content, or the first tab's content by default + const selectedTab = tabs?.find((tab: any) => tab.key === mockSelectedTabKey) ?? tabs?.[0]; + return React.createElement(View, {}, selectedTab ? selectedTab.content : null); + }), +})); + +jest.mock('@/components/ui/badge', () => ({ + Badge: jest.fn().mockImplementation(({ children, ...props }) => { + const React = require('react'); + return React.createElement('badge', props, children); + }), + BadgeText: jest.fn().mockImplementation(({ children }) => { + const React = require('react'); + return React.createElement('badge-text', {}, children); }), })); @@ -310,6 +324,10 @@ jest.mock('@/components/call-video-feeds/video-feed-tab-content', () => ({ VideoFeedTabContent: () => null, })); +jest.mock('@/components/calls/call-site-info-tab-panel', () => ({ + CallSiteInfoTabPanel: () => null, +})); + jest.mock('@/stores/check-in-timers/store', () => ({ useCheckInTimerStore: jest.fn((selector: any) => selector({ @@ -506,6 +524,7 @@ describe('CallDetail', () => { beforeEach(() => { jest.clearAllMocks(); + mockSelectedTabKey = undefined; mockUseAnalytics.mockReturnValue({ trackEvent: mockTrackEvent, @@ -1395,4 +1414,72 @@ describe('CallDetail', () => { }); }); }); + + describe('Timeline activity link markers', () => { + const renderTimeline = (activity: Record[]) => { + const store = { + call: { CallId: 'test-call-id', Name: 'Test Call', Number: 'C2024001', Priority: 1, Type: 'Fire', Address: '123 Main St', Latitude: null, Longitude: null }, + callExtraData: { Protocols: [], Dispatches: [], Activity: activity }, + callPriority: { Name: 'High', Color: '#ff0000' }, + isLoading: false, + error: null, + fetchCallDetail: jest.fn(), + reset: jest.fn(), + }; + mockUseCallDetailStore.mockImplementation((selector: any) => (selector ? selector(store) : store)); + mockSelectedTabKey = 'timeline'; + + return render(); + }; + + const activityEntry = (StatusText: string, DestinationSource?: number | null) => ({ + Id: StatusText, + Timestamp: '2024-01-01T12:00:00Z', + Type: 'Unit', + Name: 'Engine 1', + Group: 'Station 1', + Note: '', + StatusText, + StatusColor: '#000000', + ...(DestinationSource === undefined ? {} : { DestinationSource }), + }); + + it.each([[2], [3], [4]])('should mark a status auto-linked from source %p', (source) => { + const { getByTestId, queryByTestId, toJSON } = renderTimeline([activityEntry('Responding', source)]); + + // The mocked Text renders bare strings, so assert on the tree rather than getByText. + expect(JSON.stringify(toJSON())).toContain('Responding'); + const marker = getByTestId('activity-link-marker-auto'); + expect(marker.props.accessibilityLabel).toBe('call_detail.activity_link.auto'); + expect(marker.props.accessibilityHint).toBe('call_detail.activity_link.auto_hint'); + expect(marker.findByType('badge-text' as any).props.children).toBe('call_detail.activity_link.auto'); + expect(queryByTestId('activity-link-marker-inferred')).toBeNull(); + }); + + it('should mark an inferred status (source 5)', () => { + const { getByTestId, queryByTestId } = renderTimeline([activityEntry('On Scene', 5)]); + + const marker = getByTestId('activity-link-marker-inferred'); + expect(marker.props.accessibilityLabel).toBe('call_detail.activity_link.inferred'); + expect(marker.props.accessibilityHint).toBe('call_detail.activity_link.inferred_hint'); + expect(marker.findByType('badge-text' as any).props.children).toBe('call_detail.activity_link.inferred'); + expect(queryByTestId('activity-link-marker-auto')).toBeNull(); + }); + + it.each([[1], [null], [undefined]])('should not mark a status with source %p', (source) => { + const { queryByTestId, toJSON } = renderTimeline([activityEntry('Available', source)]); + + expect(JSON.stringify(toJSON())).toContain('Available'); + expect(queryByTestId('activity-link-marker-auto')).toBeNull(); + expect(queryByTestId('activity-link-marker-inferred')).toBeNull(); + }); + + it('should mark only the entries that were auto-linked or inferred', () => { + const { getAllByTestId, toJSON } = renderTimeline([activityEntry('Available', 1), activityEntry('Responding', 3), activityEntry('On Scene', 5), activityEntry('Dispatched')]); + + expect(JSON.stringify(toJSON())).toContain('Dispatched'); + expect(getAllByTestId('activity-link-marker-auto')).toHaveLength(1); + expect(getAllByTestId('activity-link-marker-inferred')).toHaveLength(1); + }); + }); }); diff --git a/src/app/records/[id].tsx b/src/app/records/[id].tsx new file mode 100644 index 00000000..cdebc466 --- /dev/null +++ b/src/app/records/[id].tsx @@ -0,0 +1,211 @@ +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView } from 'react-native'; + +import { getRecord } from '@/api/records/records'; +import { RecordAttachments } from '@/components/records/record-attachments'; +import { RecordForm } from '@/components/records/record-form'; +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Checkbox, CheckboxIcon, CheckboxIndicator, CheckboxLabel } from '@/components/ui/checkbox'; +import { Heading } from '@/components/ui/heading'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { logger } from '@/lib/logging'; +import { toValueList, toValueMap, unsupportedFieldKeys, validate, type ValidationIssue, type ValueMap } from '@/lib/records/schema'; +import { type RecordData, type RecordDefinitionSchema, RmsRecordState } from '@/models/v4/records'; +import { useRecordsStore } from '@/stores/records/store'; + +// One Record: edit while it is a draft or returned, then submit or finalize online. Amendment, void +// and approval stay Web-first (RMS plan RMS-1D), so they are not offered here. + +export default function RecordScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { id } = useLocalSearchParams<{ id: string }>(); + + const fetchSchema = useRecordsStore((state) => state.fetchSchema); + const stageDraft = useRecordsStore((state) => state.stageDraft); + const pushDraft = useRecordsStore((state) => state.pushDraft); + const discardDraft = useRecordsStore((state) => state.discardDraft); + const submitForReview = useRecordsStore((state) => state.submitForReview); + const finalize = useRecordsStore((state) => state.finalize); + const entryFor = useRecordsStore((state) => state.entryFor); + + const [record, setRecord] = useState(null); + const [schema, setSchema] = useState(null); + const [values, setValues] = useState({}); + const [issues, setIssues] = useState([]); + const [attested, setAttested] = useState(false); + const [isBusy, setIsBusy] = useState(false); + const [message, setMessage] = useState(null); + + const load = useCallback(async () => { + if (!id) { + return; + } + setIsBusy(true); + try { + const response = await getRecord(id); + const loaded = response?.Data ?? null; + setRecord(loaded); + setValues(toValueMap(loaded?.Values?.Cells)); + if (loaded?.DefinitionKey) { + setSchema(await fetchSchema(loaded.DefinitionKey, loaded.DefinitionVersion)); + } + } catch (error) { + logger.error({ message: 'Record load failed', context: { error, id } }); + setMessage(t('records.load_failed')); + } finally { + setIsBusy(false); + } + }, [id, fetchSchema, t]); + + useEffect(() => { + void load(); + }, [load]); + + const unsupported = useMemo(() => unsupportedFieldKeys(schema), [schema]); + const editable = record ? record.State === RmsRecordState.Draft || record.State === RmsRecordState.Returned : false; + const entry = record?.DefinitionKey ? entryFor(record.DefinitionKey, record.DefinitionVersion) : null; + + const save = useCallback(async () => { + if (!record || !schema) { + return; + } + const found = validate(schema, values, false); + setIssues(found); + if (found.length > 0) { + setMessage(t('records.fix_fields')); + return; + } + setIsBusy(true); + try { + const clientRecordId = `edit-${record.RecordId}-${record.RowVersion}`; + stageDraft({ + clientRecordId, + recordId: record.RecordId, + definitionKey: record.DefinitionKey ?? '', + definitionVersion: record.DefinitionVersion, + name: record.RecordNumber ?? record.DraftReference ?? entry?.Name ?? t('records.untitled'), + values: toValueList(values), + rowVersion: record.RowVersion, + updatedOn: new Date().toISOString(), + }); + const result = await pushDraft(clientRecordId); + if (result.ok) { + discardDraft(clientRecordId); + setMessage(t('records.saved')); + await load(); + return; + } + // A conflict is shown, never resolved by overwriting: the person reloads and decides. + setMessage(result.conflict ? t(`records.conflict_${result.conflict.replace('-', '_')}`) : (result.error ?? t('records.save_failed'))); + } finally { + setIsBusy(false); + } + }, [record, schema, values, entry, stageDraft, pushDraft, discardDraft, load, t]); + + const submit = useCallback(async () => { + if (!record) { + return; + } + setIsBusy(true); + try { + const result = await submitForReview(record.RecordId, record.RowVersion); + setMessage(result.ok ? t('records.submitted') : (result.error ?? t('records.save_failed'))); + if (result.ok) { + await load(); + } + } finally { + setIsBusy(false); + } + }, [record, submitForReview, load, t]); + + const complete = useCallback(async () => { + if (!record || !schema) { + return; + } + const found = validate(schema, values, true); + setIssues(found); + if (found.length > 0) { + setMessage(t('records.fix_fields')); + return; + } + setIsBusy(true); + try { + const result = await finalize(record.RecordId, record.RowVersion, attested); + setMessage(result.ok ? t('records.finalized') : (result.error ?? t('records.save_failed'))); + if (result.ok) { + await load(); + } + } finally { + setIsBusy(false); + } + }, [record, schema, values, attested, finalize, load, t]); + + if (!record) { + return ( + + + {isBusy ? : {message ?? t('records.not_found')}} + + ); + } + + return ( + + + + + {entry?.Name ?? record.DefinitionKey ?? t('records.title')} + + {record.StateName ?? RmsRecordState[record.State] ?? ''} + + + + {unsupported.length > 0 ? ( + + {t('records.update_required')} + {t('records.unsupported_definition')} + + ) : null} + + {message ? {message} : null} + + {schema ? 0} forFinalize={false} onChange={setValues} /> : } + + {/* Attachments upload against a server-owned session, so an interruption resumes. */} + + + {editable && unsupported.length === 0 ? ( + + + + + + + + {t('records.attestation')} + + + + ) : null} + + + + + + ); +} diff --git a/src/app/records/connectors/[id].tsx b/src/app/records/connectors/[id].tsx new file mode 100644 index 00000000..fbed8aec --- /dev/null +++ b/src/app/records/connectors/[id].tsx @@ -0,0 +1,189 @@ +import { Redirect, Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshControl, ScrollView } from 'react-native'; + +import { ConnectorRunItem, ReconciliationListItem } from '@/components/records/deployment-items'; +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Divider } from '@/components/ui/divider'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { connectorStateAction, formatWhen } from '@/lib/records/deployments'; +import { useDeploymentsStore } from '@/stores/records/deployments-store'; +import { securityStore } from '@/stores/security/store'; + +// One connector (RMS plan section 4.1), department administrators only: its state, the one command +// a field client may issue — read the feed now — its run log, and its open reconciliation. Enabling, +// terms, credentials and the inbound token are Web-only; write authority does not exist at all. + +const Row = ({ label, value, testID }: { label: string; value?: string | null; testID?: string }) => + value ? ( + + {label} + + {value} + + + ) : null; + +export default function ConnectorScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { id } = useLocalSearchParams<{ id: string }>(); + const isAdmin = !!securityStore((state) => state.rights)?.IsAdmin; + + const connector = useDeploymentsStore((state) => state.connectors.find((existing) => existing.Id === id) ?? null); + const runs = useDeploymentsStore((state) => (id ? (state.runs[id] ?? []) : [])); + const reconciliation = useDeploymentsStore((state) => state.reconciliation); + const runningConnectorId = useDeploymentsStore((state) => state.runningConnectorId); + const connectorsError = useDeploymentsStore((state) => state.connectorsError); + const fetchConnector = useDeploymentsStore((state) => state.fetchConnector); + const fetchRuns = useDeploymentsStore((state) => state.fetchRuns); + const fetchReconciliation = useDeploymentsStore((state) => state.fetchReconciliation); + const runConnector = useDeploymentsStore((state) => state.runConnector); + + const [refreshing, setRefreshing] = useState(false); + const [message, setMessage] = useState(null); + + const load = useCallback(async () => { + if (!id) { + return; + } + setRefreshing(true); + await Promise.all([fetchConnector(id), fetchRuns(id), fetchReconciliation(id)]); + setRefreshing(false); + }, [id, fetchConnector, fetchRuns, fetchReconciliation]); + + useFocusEffect( + useCallback(() => { + if (isAdmin) { + void load(); + } + }, [isAdmin, load]) + ); + + const items = useMemo(() => reconciliation.filter((item) => item.ConnectorId === id), [reconciliation, id]); + const isRunning = runningConnectorId === id; + + const readNow = async () => { + if (!id) { + return; + } + setMessage(null); + const result = await runConnector(id); + if (result.run) { + setMessage( + t(result.ok ? 'records.run_finished' : 'records.run_not_ok', { + outcome: t(`records.run_outcome_${result.run.Outcome}`, { defaultValue: result.run.Outcome }), + created: result.run.OrdersCreated, + snapshots: result.run.SnapshotsRecorded, + requests: result.run.RequestsAdded, + conflicts: result.run.Conflicts, + }) + ); + } else { + setMessage(t(`records.${result.error ?? 'run_failed'}`, { defaultValue: result.error ?? t('records.run_failed') })); + } + }; + + if (!isAdmin) { + return ; + } + + return ( + + + + + void load()} />}> + {connectorsError ? ( + + {t(`records.${connectorsError}`, { defaultValue: connectorsError })} + + ) : null} + {message ? ( + + {message} + + ) : null} + + {connector ? ( + + + + + {connector.Name} + + + {connector.IsReadyToRun ? t('records.connector_ready') : connector.IsEnabled ? t('records.connector_not_ready') : t('records.connector_disabled')} + + + + {connector.ProviderKey} · {connector.SourceScheme} · {connector.ProfileKey} + + + {connector.BaseUrl} + + + + + + + + + + + + + + + + + + + {t('records.manage_on_web')} + + + + {t('records.reconciliation')} + + {items.length === 0 ? ( + {t('records.no_reconciliation')} + ) : ( + <> + {t('records.reconciliation_hint')} + {items.map((item, index) => ( + router.push(`/records/deployments/${item.OrderId}`)} + testID={`connector-reconciliation-${index}`} + /> + ))} + + )} + + + + {t('records.run_log')} + + {runs.length === 0 ? {t('records.no_runs')} : runs.map((run) => )} + + ) : !connectorsError ? ( + {t('records.loading')} + ) : null} + + + + ); +} diff --git a/src/app/records/connectors/index.tsx b/src/app/records/connectors/index.tsx new file mode 100644 index 00000000..d7372f1a --- /dev/null +++ b/src/app/records/connectors/index.tsx @@ -0,0 +1,112 @@ +import { Redirect, Stack, useFocusEffect, useRouter } from 'expo-router'; +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshControl, ScrollView } from 'react-native'; + +import { ConnectorListItem, ReconciliationListItem } from '@/components/records/deployment-items'; +import { Box } from '@/components/ui/box'; +import { Divider } from '@/components/ui/divider'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { Heading } from '@/components/ui/heading'; +import { Text } from '@/components/ui/text'; +import { useDeploymentsStore } from '@/stores/records/deployments-store'; +import { securityStore } from '@/stores/security/store'; + +// External ordering-system connectors (RMS plan section 4.1), department administrators only. Shows +// each connector's state and the open reconciliation across them. A connector is created, changed +// and given its credential on the Web; here an administrator can watch it and read the feed now. + +export default function ConnectorsScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const isAdmin = !!securityStore((state) => state.rights)?.IsAdmin; + + const connectors = useDeploymentsStore((state) => state.connectors); + const reconciliation = useDeploymentsStore((state) => state.reconciliation); + const connectorsError = useDeploymentsStore((state) => state.connectorsError); + const fetchConnectors = useDeploymentsStore((state) => state.fetchConnectors); + const fetchReconciliation = useDeploymentsStore((state) => state.fetchReconciliation); + const [refreshing, setRefreshing] = React.useState(false); + + const load = useCallback(async () => { + setRefreshing(true); + await Promise.all([fetchConnectors(), fetchReconciliation()]); + setRefreshing(false); + }, [fetchConnectors, fetchReconciliation]); + + useFocusEffect( + useCallback(() => { + if (isAdmin) { + void load(); + } + }, [isAdmin, load]) + ); + + const countsByConnector = useMemo( + () => + reconciliation.reduce>((counts, item) => { + if (item.ConnectorId) { + counts[item.ConnectorId] = (counts[item.ConnectorId] ?? 0) + 1; + } + return counts; + }, {}), + [reconciliation] + ); + + // The server refuses a non-administrator anyway; the redirect just spares them the refusal. + if (!isAdmin) { + return ; + } + + return ( + + + + + void load()} />}> + {t('records.connectors_hint')} + {t('records.manage_on_web')} + + {connectorsError ? ( + + {t(`records.${connectorsError}`, { defaultValue: connectorsError })} + + ) : null} + + {connectors.length === 0 && !refreshing ? ( + {t('records.no_connectors')} + ) : ( + connectors.map((connector) => ( + router.push(`/records/connectors/${connector.Id}`)} + testID={`connector-${connector.Id}`} + /> + )) + )} + + + + {t('records.reconciliation')} + + {t('records.reconciliation_hint')} + {reconciliation.length === 0 ? ( + {t('records.no_reconciliation')} + ) : ( + reconciliation.map((item, index) => ( + router.push(`/records/deployments/${item.OrderId}`)} + testID={`reconciliation-${index}`} + /> + )) + )} + + + + ); +} diff --git a/src/app/records/deployments/[id].tsx b/src/app/records/deployments/[id].tsx new file mode 100644 index 00000000..044b5c08 --- /dev/null +++ b/src/app/records/deployments/[id].tsx @@ -0,0 +1,165 @@ +import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshControl, ScrollView } from 'react-native'; + +import { DeploymentFillItem, ReconciliationListItem } from '@/components/records/deployment-items'; +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Divider } from '@/components/ui/divider'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { deploymentStatusAction, formatWhen, isConnectorOwned, reconciliationFor, statusKey } from '@/lib/records/deployments'; +import { openLinkInBrowser } from '@/lib/utils'; +import { useDeploymentsStore } from '@/stores/records/deployments-store'; +import { securityStore } from '@/stores/security/store'; + +// One deployment: the order as the source last described it, the fills the department owes, and — +// for a department administrator — where the source and the department's record disagree. Fills +// are moved by a coordinator on the Web; nothing on this screen changes one. + +const Row = ({ label, value }: { label: string; value?: string | null }) => + value ? ( + + {label} + + {value} + + + ) : null; + +export default function DeploymentScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { id } = useLocalSearchParams<{ id: string }>(); + const isAdmin = !!securityStore((state) => state.rights)?.IsAdmin; + + const deployment = useDeploymentsStore((state) => state.deployments.find((existing) => existing.OrderId === id) ?? null); + const reconciliation = useDeploymentsStore((state) => state.reconciliation); + const isLoading = useDeploymentsStore((state) => state.isLoading); + const error = useDeploymentsStore((state) => state.error); + const fetchDeployment = useDeploymentsStore((state) => state.fetchDeployment); + const fetchReconciliation = useDeploymentsStore((state) => state.fetchReconciliation); + + useFocusEffect( + useCallback(() => { + if (!id) { + return; + } + void fetchDeployment(id); + if (isAdmin) { + void fetchReconciliation(); + } + }, [id, isAdmin, fetchDeployment, fetchReconciliation]) + ); + + const items = useMemo(() => reconciliationFor(reconciliation, id), [reconciliation, id]); + const connectorOwned = isConnectorOwned(deployment); + + return ( + + + + + id && void fetchDeployment(id)} />}> + {error && !deployment ? ( + + {t(`records.${error}`, { defaultValue: error })} + + ) : null} + + {deployment ? ( + + + + + {deployment.OrderNumber} + + + {t(`records.deployment_status_${statusKey(deployment.Status)}`, { defaultValue: deployment.Status })} + + + + {deployment.IncidentName} + {deployment.IncidentNumber ? ` · ${deployment.IncidentNumber}` : ''} + + + {connectorOwned ? ( + + {t('records.connector_maintained')} + + ) : null} + {deployment.IsPreview ? ( + + {t('records.preview')} + + ) : null} + + {deployment.ProvenanceStatement ? {deployment.ProvenanceStatement} : null} + + + + + + + + + + + + + + + {deployment.RecordId ? ( + + ) : null} + {deployment.ArtifactSafeUrl ? ( + + ) : null} + + + + + {t('records.fills')} + + {deployment.Fills.length === 0 ? ( + {t('records.no_fills')} + ) : ( + deployment.Fills.map((fill) => ) + )} + + + {isAdmin && connectorOwned ? ( + <> + + {t('records.reconciliation')} + + {items.length === 0 ? ( + {t('records.no_reconciliation')} + ) : ( + <> + {t('records.reconciliation_hint')} + {items.map((item, index) => ( + + ))} + + )} + + ) : null} + + ) : !error ? ( + {t('records.loading')} + ) : null} + + + + ); +} diff --git a/src/app/records/deployments/index.tsx b/src/app/records/deployments/index.tsx new file mode 100644 index 00000000..a0d4d98a --- /dev/null +++ b/src/app/records/deployments/index.tsx @@ -0,0 +1,98 @@ +import { Stack, useFocusEffect, useRouter } from 'expo-router'; +import { RadioTower } from 'lucide-react-native'; +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshControl, ScrollView } from 'react-native'; + +import { DeploymentListItem, RecordsEntryRow } from '@/components/records/deployment-items'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Divider } from '@/components/ui/divider'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { Text } from '@/components/ui/text'; +import { reconciliationCounts } from '@/lib/records/deployments'; +import { useDeploymentsStore } from '@/stores/records/deployments-store'; +import { securityStore } from '@/stores/security/store'; + +// Deployments (RMS plan section 4.1): the external resource orders this department is filling, each +// with the fills it owes. The ordering system stays authoritative — a connector or a coordinator on +// the Web maintains the source snapshots — so this list is read-only here. Department administrators +// also see how many reconciliation items each order carries and can reach the connectors from here. + +export default function DeploymentsScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const isAdmin = !!securityStore((state) => state.rights)?.IsAdmin; + + const deployments = useDeploymentsStore((state) => state.deployments); + const includeClosed = useDeploymentsStore((state) => state.includeClosed); + const reconciliation = useDeploymentsStore((state) => state.reconciliation); + const isLoading = useDeploymentsStore((state) => state.isLoading); + const error = useDeploymentsStore((state) => state.error); + const fetchDeployments = useDeploymentsStore((state) => state.fetchDeployments); + const fetchReconciliation = useDeploymentsStore((state) => state.fetchReconciliation); + + useFocusEffect( + useCallback(() => { + void fetchDeployments(); + if (isAdmin) { + void fetchReconciliation(); + } + }, [fetchDeployments, fetchReconciliation, isAdmin]) + ); + + const counts = useMemo(() => reconciliationCounts(reconciliation), [reconciliation]); + + return ( + + + + + void fetchDeployments()} />}> + {t('records.deployments_hint')} + + {error ? ( + + {t(`records.${error}`, { defaultValue: error })} + + ) : null} + + {isAdmin ? ( + <> + } + title={t('records.connectors')} + subtitle={t('records.connectors_hint')} + count={reconciliation.length} + attention + onPress={() => router.push('/records/connectors')} + testID="deployments-connectors" + /> + + + ) : null} + + {deployments.length === 0 && !isLoading ? ( + {t('records.no_deployments')} + ) : ( + deployments.map((deployment) => ( + router.push(`/records/deployments/${deployment.OrderId}`)} + testID={`deployment-${deployment.OrderId}`} + /> + )) + )} + + + + + + + + ); +} diff --git a/src/app/records/new.tsx b/src/app/records/new.tsx new file mode 100644 index 00000000..ba50d834 --- /dev/null +++ b/src/app/records/new.tsx @@ -0,0 +1,245 @@ +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; +import { ChevronRight, CloudOff } from 'lucide-react-native'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView } from 'react-native'; + +import { RecordForm } from '@/components/records/record-form'; +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Divider } from '@/components/ui/divider'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { useRecordsContext } from '@/hooks/use-records-context'; +import { canAuthorOffline, cellKey, toValueList, unsupportedFieldKeys, validate, type ValidationIssue, type ValueMap } from '@/lib/records/schema'; +import { type FieldRecordCatalogEntry, type RecordDefinitionSchema } from '@/models/v4/records'; +import { useRecordsStore } from '@/stores/records/store'; + +// Contextual create: pick a definition the server offered for this context, then author it. The +// catalog is the only source of what may be started here — nothing is inferred locally. + +const clientId = () => `draft-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + +export default function NewRecordScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const params = useLocalSearchParams<{ draft?: string; definitionKey?: string }>(); + const context = useRecordsContext(); + + const catalog = useRecordsStore((state) => state.catalog); + const pendingDrafts = useRecordsStore((state) => state.pendingDrafts); + const fetchCatalog = useRecordsStore((state) => state.fetchCatalog); + const fetchSchema = useRecordsStore((state) => state.fetchSchema); + const prefill = useRecordsStore((state) => state.prefill); + const stageDraft = useRecordsStore((state) => state.stageDraft); + const discardDraft = useRecordsStore((state) => state.discardDraft); + const pushDraft = useRecordsStore((state) => state.pushDraft); + const report = useRecordsStore((state) => state.report); + + const resumed = params.draft ? pendingDrafts[params.draft] : undefined; + const [entry, setEntry] = useState(null); + const [schema, setSchema] = useState(null); + const [values, setValues] = useState({}); + const [issues, setIssues] = useState([]); + const [isBusy, setIsBusy] = useState(false); + const [message, setMessage] = useState(null); + + useEffect(() => { + if (!catalog) { + void fetchCatalog(); + } + }, [catalog, fetchCatalog]); + + const choose = useCallback( + async (chosen: FieldRecordCatalogEntry, existing?: ValueMap) => { + setIsBusy(true); + try { + const loaded = await fetchSchema(chosen.DefinitionKey, chosen.Version); + setEntry(chosen); + setSchema(loaded); + if (existing) { + setValues(existing); + return; + } + if (!chosen.SupportsPrefill) { + setValues({}); + return; + } + report({ EventType: 'draft_started', Outcome: 'ok', DefinitionKey: chosen.DefinitionKey, DefinitionVersion: chosen.Version }); + const prefilled = await prefill(chosen.DefinitionKey, chosen.Version); + const map: ValueMap = {}; + for (const value of prefilled?.Values ?? []) { + if (!value.SectionKey) { + continue; + } + map[cellKey(value.SectionKey, value.FieldKey, null)] = { + SectionKey: value.SectionKey, + FieldKey: value.FieldKey, + RowKey: null, + Value: value.Value ?? null, + ReferenceType: value.ReferenceType ?? null, + ReferenceId: value.ReferenceId ?? null, + }; + } + setValues(map); + } finally { + setIsBusy(false); + } + }, + [fetchSchema, prefill, report] + ); + + useEffect(() => { + if (!resumed || entry) { + return; + } + const match = (catalog?.Definitions ?? []).find((definition) => definition.DefinitionKey === resumed.definitionKey && definition.Version === resumed.definitionVersion); + if (!match) { + return; + } + const map: ValueMap = {}; + for (const value of resumed.values) { + map[cellKey(value.SectionKey, value.FieldKey, value.RowKey ?? null)] = value; + } + void choose(match, map); + }, [resumed, entry, catalog, choose]); + + const unsupported = useMemo(() => unsupportedFieldKeys(schema), [schema]); + const offlineCapable = canAuthorOffline(entry); + + const save = useCallback( + async (send: boolean) => { + if (!entry || !schema) { + return; + } + const found = validate(schema, values, false); + setIssues(found); + if (found.length > 0) { + setMessage(t('records.fix_fields')); + return; + } + + const draftId = resumed?.clientRecordId ?? clientId(); + const draft = { + clientRecordId: draftId, + recordId: resumed?.recordId ?? null, + definitionKey: entry.DefinitionKey, + definitionVersion: entry.Version, + name: entry.Name, + values: toValueList(values), + callId: context.CallId ?? null, + stationGroupId: context.GroupId ?? null, + rowVersion: resumed?.rowVersion ?? null, + updatedOn: new Date().toISOString(), + }; + + if (!send) { + // Explicit local save. A definition that seals values is never written to the device, so the + // store refuses to stage it and the person is told to stay online for it. + stageDraft(draft); + setMessage(offlineCapable ? t('records.saved_locally') : t('records.online_only')); + return; + } + + setIsBusy(true); + try { + // Staged first so an interrupted send leaves the work on the device rather than losing it. + stageDraft(draft); + const result = await pushDraft(draftId); + if (result.ok && result.recordId) { + discardDraft(draftId); + router.replace(`/records/${result.recordId}`); + return; + } + setMessage(result.conflict ? t(`records.conflict_${result.conflict.replace('-', '_')}`) : (result.error ?? t('records.save_failed'))); + } finally { + setIsBusy(false); + } + }, + [entry, schema, values, resumed, context, stageDraft, offlineCapable, pushDraft, discardDraft, router, t] + ); + + if (!entry) { + const definitions = catalog?.Definitions ?? []; + return ( + + + + {definitions.length === 0 ? ( + {t('records.nothing_to_create')} + ) : ( + definitions.map((definition) => ( + + void choose(definition)} testID={`records-choose-${definition.DefinitionKey}`}> + + + {definition.Name} + + {definition.Category ? {definition.Category} : null} + {definition.RequiresProtectedGrant ? ( + + {t('records.online_only_badge')} + + ) : null} + {definition.AllowOffline ? ( + + {t('records.offline_capable')} + + ) : null} + + + + + + + + )) + )} + + + ); + } + + return ( + + + + {unsupported.length > 0 ? ( + + {t('records.update_required')} + {t('records.unsupported_definition')} + + ) : null} + + {!offlineCapable ? ( + + + {t('records.online_only')} + + ) : null} + + {message ? {message} : null} + + {schema ? 0} /> : } + + + + + + + + + + ); +} diff --git a/src/components/audio-stream/__tests__/audio-stream-bottom-sheet.test.tsx b/src/components/audio-stream/__tests__/audio-stream-bottom-sheet.test.tsx new file mode 100644 index 00000000..485001ec --- /dev/null +++ b/src/components/audio-stream/__tests__/audio-stream-bottom-sheet.test.tsx @@ -0,0 +1,169 @@ +import { cleanup, render } from '@testing-library/react-native'; +import React from 'react'; + +import { useAudioStreamStore } from '@/stores/app/audio-stream-store'; + +import { AudioStreamBottomSheet } from '../audio-stream-bottom-sheet'; + +jest.mock('nativewind', () => ({ + styled: jest.fn((Component: any) => Component), + useColorScheme: () => ({ colorScheme: 'light' }), + cssInterop: jest.fn(), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('lucide-react-native', () => ({ + Loader: () => null, + Volume2: () => null, + VolumeX: () => null, +})); + +jest.mock('@/components/ui/text', () => { + const { Text } = require('react-native'); + return { Text }; +}); + +jest.mock('../../ui/actionsheet', () => { + const { View } = require('react-native'); + return { + Actionsheet: ({ isOpen, children }: any) => (isOpen ? {children} : null), + ActionsheetBackdrop: () => null, + ActionsheetContent: ({ children }: any) => {children}, + ActionsheetDragIndicator: () => null, + ActionsheetDragIndicatorWrapper: ({ children }: any) => {children}, + }; +}); + +jest.mock('../../ui/button', () => { + const { Text, TouchableOpacity } = require('react-native'); + return { + Button: ({ children, onPress }: any) => {children}, + ButtonText: ({ children }: any) => {children}, + }; +}); + +jest.mock('../../ui/hstack', () => { + const { View } = require('react-native'); + return { HStack: ({ children }: any) => {children} }; +}); + +jest.mock('../../ui/vstack', () => { + const { View } = require('react-native'); + return { VStack: ({ children }: any) => {children} }; +}); + +const selectProps: Record[] = []; +const selectInputProps: Record[] = []; + +jest.mock('../../ui/select', () => { + const { View } = require('react-native'); + return { + Select: ({ children, ...props }: any) => { + selectProps.push(props); + return {children}; + }, + SelectBackdrop: () => null, + SelectContent: ({ children }: any) => {children}, + SelectDragIndicator: () => null, + SelectDragIndicatorWrapper: ({ children }: any) => {children}, + SelectIcon: () => null, + SelectInput: (props: any) => { + selectInputProps.push(props); + return null; + }, + SelectItem: () => null, + SelectPortal: ({ children }: any) => {children}, + SelectTrigger: ({ children }: any) => {children}, + }; +}); + +jest.mock('@/stores/app/audio-stream-store', () => ({ + useAudioStreamStore: jest.fn(), +})); + +const mockUseAudioStreamStore = useAudioStreamStore as unknown as jest.Mock; + +const stream = { Id: '3f1a9c6e-6f2b-4f0a-9f31-9a0e3c0f77aa', Name: 'Dispatch Primary', Type: 'Icecast', Url: 'https://relay/live' }; + +const buildState = (overrides: Record = {}) => ({ + availableStreams: [stream], + isLoadingStreams: false, + currentStream: stream, + isPlaying: true, + isLoading: false, + isBuffering: false, + isBottomSheetVisible: true, + setIsBottomSheetVisible: jest.fn(), + fetchAvailableStreams: jest.fn(), + playStream: jest.fn(), + stopStream: jest.fn(), + ...overrides, +}); + +const mockStore = (overrides: Record = {}) => { + const state = buildState(overrides); + mockUseAudioStreamStore.mockImplementation((selector: (s: typeof state) => unknown) => selector(state)); +}; + +const lastSelectInputValue = () => selectInputProps[selectInputProps.length - 1]?.value; + +beforeEach(() => { + selectProps.length = 0; + selectInputProps.length = 0; + jest.clearAllMocks(); +}); + +afterEach(() => { + cleanup(); +}); + +describe('AudioStreamBottomSheet', () => { + it('shows the stream name in the trigger, not the stream id', () => { + mockStore(); + + const { unmount } = render(); + expect(selectProps[0]).toEqual(expect.objectContaining({ selectedValue: stream.Id })); + expect(lastSelectInputValue()).toBe(stream.Name); + unmount(); + }); + + it('still shows the stream name after the sheet is closed and reopened', () => { + mockStore(); + + const { unmount } = render(); + unmount(); + + // Reopening remounts the Select (the actionsheet unmounts its children on close), so the + // displayed label has to come from the store rather than a prior item press. + selectInputProps.length = 0; + const reopened = render(); + expect(lastSelectInputValue()).toBe(stream.Name); + reopened.unmount(); + }); + + it('falls back to the none label when no stream is selected', () => { + mockStore({ currentStream: null, isPlaying: false }); + + const { unmount } = render(); + expect(selectProps[0]).toEqual(expect.objectContaining({ selectedValue: 'none' })); + expect(lastSelectInputValue()).toBe('audio_streams.none'); + unmount(); + }); + + it('drops the stale stream name when playback clears the current stream while open', () => { + mockStore(); + + const { rerender, unmount } = render(); + expect(lastSelectInputValue()).toBe(stream.Name); + + // A playback error clears currentStream without any item press. + mockStore({ currentStream: null, isPlaying: false }); + rerender(); + + expect(lastSelectInputValue()).toBe('audio_streams.none'); + unmount(); + }); +}); diff --git a/src/components/audio-stream/audio-stream-bottom-sheet.tsx b/src/components/audio-stream/audio-stream-bottom-sheet.tsx index f3103d47..cd195962 100644 --- a/src/components/audio-stream/audio-stream-bottom-sheet.tsx +++ b/src/components/audio-stream/audio-stream-bottom-sheet.tsx @@ -59,6 +59,16 @@ export const AudioStreamBottomSheet = () => { return currentStream ? currentStream.Id : 'none'; }; + // The Select only tracks an option's label in internal state set when that option is pressed: + // it never maps `selectedValue` back to a label. So the trigger falls back to rendering the + // raw value -- the stream's GUID -- whenever the label state is missing (the Actionsheet + // unmounts its children on close, so every reopen starts empty) or stale (playback failing + // clears currentStream without a press). Driving SelectInput's value directly keeps the + // trigger in sync with the store in both cases. + const getCurrentStreamLabel = () => { + return currentStream ? (currentStream.Name ?? '') : t('audio_streams.none'); + }; + const getDisplayText = () => { if (isLoading) { return t('audio_streams.loading_stream'); @@ -106,7 +116,7 @@ export const AudioStreamBottomSheet = () => { ) : ( + + + + + + + + + + + + + {receipt ? {t('operations.expenses.receiptAttached')} : null} + + + ) : null} + {message ? {message} : null} + {others.length > 0 ? ( + + {t('operations.expenses.otherDays')} + {others.map(row)} + + ) : null} + + ); +}; diff --git a/src/components/operations/mars-panel.tsx b/src/components/operations/mars-panel.tsx new file mode 100644 index 00000000..f0e0441d --- /dev/null +++ b/src/components/operations/mars-panel.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import type { CalOesMarsQueueItem, CalOesMarsValidation } from '@/models/v4/operations'; + +interface MarsPanelProps { + items: CalOesMarsQueueItem[]; + validation: CalOesMarsValidation | null; + canDraft: boolean; + busy: boolean; + onDraft: () => void; + onValidate: (workItemId: string) => void; +} + +// CAL OES MARS (Phase C-M3) from the field: draft the F-42 from the deployment's time reports and run +// the portal validation. Submission itself stays a manual, audited hand-off on the web. +export const MarsPanel = ({ items, validation, canDraft, busy, onDraft, onValidate }: MarsPanelProps) => { + const { t } = useTranslation(); + return ( + + {items.length === 0 ? {t('operations.mars.none')} : null} + {items.map((item) => ( + + + {item.RecordTypeName ?? t('operations.mars.f42')} + {item.LocalStateName ?? String(item.LocalState)} + + {item.MarsRecordId ? {t('operations.mars.record', { id: item.MarsRecordId, status: item.ObservedExternalStatus ?? '—' })} : null} + {t('operations.mars.counts', { errors: item.ErrorCount, warnings: item.WarningCount })} + + {validation?.WorkItemId === item.Id ? ( + + {validation.IsReadyForPortal ? t('operations.mars.ready') : t('operations.mars.notReady')} + {validation.Errors.map((issue, i) => ( + + {issue.Box ? `${issue.Box}: ` : ''} + {issue.Detail ?? issue.Code} + + ))} + {validation.Warnings.map((issue, i) => ( + + {issue.Box ? `${issue.Box}: ` : ''} + {issue.Detail ?? issue.Code} + + ))} + + ) : null} + + ))} + {canDraft ? ( + + ) : null} + {t('operations.mars.help')} + + ); +}; diff --git a/src/components/operations/option-select.tsx b/src/components/operations/option-select.tsx new file mode 100644 index 00000000..1a2917fa --- /dev/null +++ b/src/components/operations/option-select.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +import { ChevronDownIcon } from '@/components/ui/lucide-icons'; +import { Select, SelectBackdrop, SelectContent, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select'; + +export interface SelectOption { + value: string; + label: string; +} + +interface OptionSelectProps { + value: string; + options: SelectOption[]; + placeholder: string; + onChange: (value: string) => void; + isDisabled?: boolean; + testID?: string; +} + +// gluestack's Select only knows the label of an option the person picked; pass the current one so a +// value that came from the server renders as its label instead of the placeholder. +export const OptionSelect = ({ value, options, placeholder, onChange, isDisabled, testID }: OptionSelectProps) => { + const current = options.find((option) => option.value === value); + return ( + + ); +}; diff --git a/src/components/operations/scope-picker.tsx b/src/components/operations/scope-picker.tsx new file mode 100644 index 00000000..7345ef26 --- /dev/null +++ b/src/components/operations/scope-picker.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView } from 'react-native'; + +import { Button, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { dateOf, reportScope, scopeKey, scopeName, sumHours, type TimeScope } from '@/lib/operations/time'; +import type { Deployment, TimeReport } from '@/models/v4/operations'; + +export const useScopeLabel = () => { + const { t } = useTranslation(); + return (deployment: Deployment, scope: TimeScope | null) => { + if (!scope) return ''; + if (scope.kind === 'deployment') return t('operations.scope.deployment'); + if (scope.kind === 'individual' && scope.personnelId === deployment.TimeAccess?.PersonnelId) return t('operations.scope.mine'); + return scope.kind === 'crew' ? t('operations.scope.crew', { name: scopeName(deployment, scope) }) : t('operations.scope.individual', { name: scopeName(deployment, scope) }); + }; +}; + +interface ScopePickerProps { + deployment: Deployment; + scopes: TimeScope[]; + current: TimeScope | null; + onPick: (scope: TimeScope) => void; +} + +// Which report the person is working on: their crew's Crew Time Report, their own time, or — for a manager — +// the deployment-wide DTR. Only the scopes the server's TimeAccess grants are offered. +export const ScopePicker = ({ deployment, scopes, current, onPick }: ScopePickerProps) => { + const label = useScopeLabel(); + if (scopes.length < 2) return null; + return ( + + {scopes.map((scope) => ( + + ))} + + ); +}; + +interface DayReportsProps { + deployment: Deployment; + reports: TimeReport[]; + openId: string | null; + onOpen: (report: TimeReport) => void; +} + +// Every live report on the day (managers and approvers): who it covers, its status and hours, so a supervisor +// can open each crew's report to review and approve it. +export const DayReports = ({ deployment, reports, openId, onOpen }: DayReportsProps) => { + const { t } = useTranslation(); + const label = useScopeLabel(); + if (reports.length === 0) return null; + return ( + + {t('operations.time.dayReports')} + {reports.map((report) => ( + onOpen(report)} testID={`operations-day-report-${report.Id}`} className={`rounded-lg border p-2 ${report.Id === openId ? 'border-primary-500' : 'border-outline-200'}`}> + + + #{report.ReportNumber} · {label(deployment, reportScope(report))} + + + {t(`operations.reportStatus.${report.Status}`)} · {t('operations.time.hours', { hours: sumHours(report.Entries) })} + + + {dateOf(report.ReportDate)} + + ))} + + ); +}; + +interface AwaitingApprovalProps { + deployment: Deployment; + reports: TimeReport[]; + onOpen: (report: TimeReport) => void; +} + +/** Submitted reports on any day, oldest first: the approver's queue for this deployment. */ +export const AwaitingApproval = ({ deployment, reports, onOpen }: AwaitingApprovalProps) => { + const { t } = useTranslation(); + const label = useScopeLabel(); + if (reports.length === 0) return null; + return ( + + {t('operations.time.awaitingApproval', { count: reports.length })} + {reports.map((report) => ( + onOpen(report)} testID={`operations-awaiting-${report.Id}`}> + + {dateOf(report.ReportDate)} · #{report.ReportNumber} · {label(deployment, reportScope(report))} + + + ))} + + ); +}; diff --git a/src/components/operations/time-report-editor.tsx b/src/components/operations/time-report-editor.tsx new file mode 100644 index 00000000..bed50fc4 --- /dev/null +++ b/src/components/operations/time-report-editor.tsx @@ -0,0 +1,321 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { OptionSelect } from '@/components/operations/option-select'; +import { Button, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import type { EntrySubject } from '@/lib/operations/time'; +import { applyToCrew, combineDateTime, dateOf, endLocalFor, entryHours, hoursBySubject, isClockTime, isReportEditable, newEntry, subjectIdOf, sumHours, timeOf } from '@/lib/operations/time'; +import type { TimeEntry, TimeReport, TimeReportIssue } from '@/models/v4/operations'; +import { TimeEntryType, TimeReportScope, TimeReportStatus, TimeSubjectType } from '@/models/v4/operations'; + +interface TimeReportEditorProps { + dateKey: string; + report: TimeReport | null; + entries: TimeEntry[]; + /** Subjects the person may add rows for in this report's scope. */ + subjects: EntrySubject[]; + /** Roster names by subject id, including released members, for stored rows. */ + names: Record; + /** Short name of the crew or person the report covers ("Engine 41", "A. Smith"); empty for a deployment DTR. */ + scopeLabel: string; + isCrew: boolean; + /** Set when this person's or crew's time for the day already sits on another report. */ + coveredBy: TimeReport | null; + issues: TimeReportIssue[]; + warnings: TimeReportIssue[]; + canCreate: boolean; + canApprove: boolean; + canWrite: (subjectId: string) => boolean; + dirty: boolean; + busy: boolean; + onStart: () => void; + onChange: (entries: TimeEntry[]) => void; + onSave: () => void; + onSubmit: () => void; + onSign: (crewBossSigned: boolean, customerSignerName?: string | null) => void; + onApprove: () => void; +} + +// Keeps the keystrokes local and only commits a complete HH:mm, so a half-typed time never lands +// in the entry as an unparseable value. +const ClockField = ({ value, onCommit, isDisabled, testID, label }: { value: string; onCommit: (clock: string) => void; isDisabled: boolean; testID: string; label: string }) => { + const [text, setText] = useState(value); + useEffect(() => { + setText(value); + }, [value]); + return ( + + { + setText(next); + if (isClockTime(next)) onCommit(next); + }} + /> + + ); +}; + +const typeRank = (entry: TimeEntry) => (entry.SubjectType === TimeSubjectType.Unit ? 0 : entry.SubjectType === TimeSubjectType.Personnel ? 1 : 2); + +// A crew or individual time report for one day (the paper CTR / OF-288 analog). On a crew report the unit's +// row leads; "Apply to crew" copies its shift to every crew member and piece of equipment on the same entry +// type, which is how most crews fill the form, and rows that differ are edited one by one. The crew boss +// signs, the agency signer's name can be recorded, and the report is submitted for approval. +export const TimeReportEditor = ({ + dateKey, + report, + entries, + subjects, + names, + scopeLabel, + isCrew, + coveredBy, + issues, + warnings, + canCreate, + canApprove, + canWrite, + dirty, + busy, + onStart, + onChange, + onSave, + onSubmit, + onSign, + onApprove, +}: TimeReportEditorProps) => { + const { t } = useTranslation(); + const [customerSigner, setCustomerSigner] = useState(''); + const editable = isReportEditable(report); + const typeOptions = [ + { value: String(TimeEntryType.Deployment), label: t('operations.entryType.0') }, + { value: String(TimeEntryType.Standby), label: t('operations.entryType.1') }, + { value: String(TimeEntryType.Travel), label: t('operations.entryType.2') }, + ]; + const subjectOptions = subjects.map((subject) => ({ value: `${subject.type}:${subject.id}`, label: subject.label })); + // Unit first, then crew, then equipment; indexes still point into the saved order. + const order = useMemo(() => entries.map((entry, index) => ({ entry, index })).sort((a, b) => typeRank(a.entry) - typeRank(b.entry) || a.index - b.index), [entries]); + const perSubject = useMemo(() => hoursBySubject(entries), [entries]); + const update = (index: number, patch: Partial) => onChange(entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry))); + const remove = (index: number) => onChange(entries.filter((_, i) => i !== index).map((entry, i) => ({ ...entry, SortOrder: i }))); + const add = () => { + const subject = subjects[0]; + if (subject) onChange([...entries, newEntry(subject, dateKey, entries.length)]); + }; + const setSubject = (index: number, value: string) => { + const subject = subjects.find((candidate) => `${candidate.type}:${candidate.id}` === value); + if (!subject) return; + const blank = newEntry(subject, dateKey, index); + update(index, { SubjectType: blank.SubjectType, DeploymentPersonnelId: blank.DeploymentPersonnelId, DeploymentUnitId: blank.DeploymentUnitId, DeploymentEquipmentId: blank.DeploymentEquipmentId }); + }; + const setStart = (index: number, clock: string) => { + const entry = entries[index]; + const startLocal = combineDateTime(dateOf(entry.StartLocal) || dateKey, clock); + update(index, { StartLocal: startLocal, EndLocal: endLocalFor(startLocal, timeOf(entry.EndLocal) || clock) }); + }; + const issueFor = (index: number) => issues.filter((issue) => issue.EntryId === String(index) || (!!entries[index]?.Id && issue.EntryId === entries[index]?.Id)); + const issueText = (issue: TimeReportIssue) => t(`operations.issues.${issue.Code}`, { defaultValue: issue.Detail ?? issue.Code }); + const title = !report + ? '' + : report.Scope === TimeReportScope.Crew + ? t('operations.time.crewReport', { number: report.ReportNumber, name: scopeLabel }) + : report.Scope === TimeReportScope.Individual + ? t('operations.time.individualReport', { number: report.ReportNumber, name: scopeLabel }) + : t('operations.time.report', { number: report.ReportNumber }); + + if (!report) { + return ( + + {isCrew ? t('operations.time.noCrewReport', { name: scopeLabel }) : t('operations.time.noReport')} + {coveredBy ? {t('operations.time.coveredBy', { number: coveredBy.ReportNumber })} : null} + {canCreate && !coveredBy ? ( + + ) : null} + + ); + } + + const signable = report.CanAct && (report.Status === TimeReportStatus.Draft || report.Status === TimeReportStatus.Submitted); + + return ( + + + {title} + {t(`operations.reportStatus.${report.Status}`)} + + {!report.CanAct ? {t('operations.time.readOnly')} : null} + {entries.length === 0 ? {t('operations.time.noEntries')} : null} + {order.map(({ entry, index }) => { + const subjectId = subjectIdOf(entry); + const rowEditable = editable && canWrite(subjectId); + const label = names[subjectId] ?? ''; + return ( + + {entry.Id || !rowEditable ? ( + {label || t('operations.time.subject')} + ) : ( + setSubject(index, value)} + testID={`operations-entry-subject-${index}`} + /> + )} + update(index, { EntryType: Number(value) })} + isDisabled={!rowEditable} + testID={`operations-entry-type-${index}`} + /> + + {t('operations.time.from')} + setStart(index, clock)} isDisabled={!rowEditable} testID={`operations-entry-start-${index}`} /> + {t('operations.time.to')} + update(index, { EndLocal: endLocalFor(entry.StartLocal, clock) })} + isDisabled={!rowEditable} + testID={`operations-entry-end-${index}`} + /> + + {dateOf(entry.EndLocal) > dateOf(entry.StartLocal) ? {t('operations.time.nextDay')} : null} + + {t('operations.time.unpaidBreak')} + + update(index, { UnpaidBreakMinutes: Math.max(0, Number(text) || 0) })} + testID={`operations-entry-break-${index}`} + /> + + + + {t('operations.time.meals')} + update(index, { AgencySuppliedMeals: value })} isDisabled={!rowEditable} /> + + + {t('operations.time.accommodation')} + update(index, { AgencySuppliedAccommodation: value })} isDisabled={!rowEditable} /> + + + update(index, { Notes: text || null })} testID={`operations-entry-notes-${index}`} /> + + + {t('operations.time.hours', { hours: entryHours(entry) })} + {rowEditable ? ( + + {isCrew && entries.length > 1 ? ( + + ) : null} + + + ) : null} + + {issueFor(index).map((issue, i) => ( + + {issueText(issue)} + + ))} + + ); + })} + {issues + .filter((issue) => !issue.EntryId || !entries.some((entry, index) => issue.EntryId === String(index) || issue.EntryId === entry.Id)) + .map((issue, i) => ( + + {issueText(issue)} + {issue.SubjectId && names[issue.SubjectId] ? ` — ${names[issue.SubjectId]}` : ''} + + ))} + {warnings.map((warning, i) => ( + + {issueText(warning)} + {warning.SubjectId && names[warning.SubjectId] ? ` — ${names[warning.SubjectId]}` : ''} + + ))} + {Object.keys(perSubject).length > 1 ? ( + + {Object.entries(perSubject).map(([subjectId, hours]) => ( + + {names[subjectId] ?? subjectId} + {t('operations.time.hours', { hours })} + + ))} + + ) : null} + {t('operations.time.total', { hours: sumHours(entries) })} + {editable ? ( + + + + + + ) : null} + {signable || report.ContractorSignedOn || report.CustomerSignedOn ? ( + + {t('operations.sign.title')} + + {report.ContractorSignedOn ? t('operations.sign.crewBossSigned', { when: String(report.ContractorSignedOn).slice(0, 16).replace('T', ' ') }) : t('operations.sign.crewBossPending')} + + {report.CustomerSignedOn ? t('operations.sign.customerSigned', { name: report.CustomerSignerName ?? '' }) : t('operations.sign.customerPending')} + {signable ? ( + <> + {!report.ContractorSignedOn ? ( + + ) : null} + + + + + + ) : null} + + ) : null} + {canApprove && report.Status === TimeReportStatus.Submitted ? ( + + ) : null} + + ); +}; diff --git a/src/components/operations/usage-form.tsx b/src/components/operations/usage-form.tsx new file mode 100644 index 00000000..c48ee7a3 --- /dev/null +++ b/src/components/operations/usage-form.tsx @@ -0,0 +1,119 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { OptionSelect } from '@/components/operations/option-select'; +import { Button, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField } from '@/components/ui/input'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import type { DeploymentUnit, ResourceUsage, ResourceUsageInput } from '@/models/v4/operations'; +import { UsagePhase } from '@/models/v4/operations'; + +interface UsageFormProps { + dateKey: string; + units: DeploymentUnit[]; + defaultUnitId: string | null; + readings: ResourceUsage[]; + busy: boolean; + onAdd: (input: Omit) => Promise; +} + +const numberOrNull = (text: string) => { + const trimmed = text.trim(); + if (!trimmed) return null; + const value = Number(trimmed); + return Number.isFinite(value) ? value : null; +}; + +// A resource usage reading (Phase E, FieldCosting): odometer / meter / fuel for one unit on one day and +// phase. Conflicting readings are flagged NeedsReview server-side; nothing here prices anything. +export const UsageForm = ({ dateKey, units, defaultUnitId, readings, busy, onAdd }: UsageFormProps) => { + const { t } = useTranslation(); + const [unitId, setUnitId] = useState(defaultUnitId ?? (units[0] ? String(units[0].UnitId) : '')); + const [phase, setPhase] = useState(String(UsagePhase.Incident)); + const [startOdometer, setStartOdometer] = useState(''); + const [endOdometer, setEndOdometer] = useState(''); + const [distanceUnit, setDistanceUnit] = useState('mi'); + const [engineHours, setEngineHours] = useState(''); + const [fuelQuantity, setFuelQuantity] = useState(''); + const [fuelUnit, setFuelUnit] = useState('gal'); + const [saved, setSaved] = useState(false); + const unitOptions = units.map((unit) => ({ value: String(unit.UnitId), label: unit.CallSign ? `${unit.UnitName} (${unit.CallSign})` : unit.UnitName })); + const phaseOptions = [UsagePhase.Mobilization, UsagePhase.Standby, UsagePhase.Incident, UsagePhase.Return].map((value) => ({ value: String(value), label: t(`operations.phase.${value}`) })); + const start = numberOrNull(startOdometer); + const end = numberOrNull(endOdometer); + const complete = !!unitId && (start != null || end != null || numberOrNull(engineHours) != null || numberOrNull(fuelQuantity) != null) && (start == null || end == null || end >= start); + + const submit = async () => { + setSaved(false); + const ok = await onAdd({ + UnitId: Number(unitId), + UsageDate: `${dateKey}T00:00:00`, + Phase: Number(phase), + StartOdometer: start, + EndOdometer: end, + DistanceUnit: distanceUnit, + Distance: start != null && end != null ? end - start : null, + EngineHours: numberOrNull(engineHours), + FuelQuantity: numberOrNull(fuelQuantity), + FuelUnit: numberOrNull(fuelQuantity) != null ? fuelUnit : null, + }); + if (ok) { + setStartOdometer(''); + setEndOdometer(''); + setEngineHours(''); + setFuelQuantity(''); + setSaved(true); + } + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + {saved ? {t('operations.usage.saved')} : null} + {readings.map((reading) => ( + + + + {unitOptions.find((option) => option.value === String(reading.UnitId))?.label ?? reading.UnitId} · {t(`operations.phase.${reading.Phase}`)} + + + {String(reading.UsageDate ?? '').slice(0, 10)} + {reading.OriginalDistance != null ? ` · ${reading.OriginalDistance} ${reading.DistanceUnit ?? ''}` : ''} + {reading.EngineHours != null ? ` · ${reading.EngineHours} h` : ''} + {reading.FuelQuantity != null ? ` · ${reading.FuelQuantity} ${reading.FuelUnit ?? ''}` : ''} + + + {reading.NeedsReview ? {t('operations.usage.needsReview')} : null} + + ))} + + ); +}; diff --git a/src/components/records/deployment-items.tsx b/src/components/records/deployment-items.tsx new file mode 100644 index 00000000..e130bf39 --- /dev/null +++ b/src/components/records/deployment-items.tsx @@ -0,0 +1,245 @@ +import { AlertTriangle, ChevronRight, GlobeIcon, RadioTower } from 'lucide-react-native'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { connectorStateAction, deploymentStatusAction, fillStatusAction, formatWhen, isConnectorOwned, runOutcomeAction, statusKey } from '@/lib/records/deployments'; +import { + type RecordDeploymentConnectorData, + type RecordDeploymentConnectorRunData, + type RecordDeploymentData, + type RecordDeploymentFillData, + type RecordDeploymentReconciliationData, +} from '@/models/v4/records/deployments'; + +// Rows for deployments, fills, reconciliation items, connectors and connector runs (RMS plan section +// 4.1). Presentation only: every value shown came from the server, and no row offers a way to change +// a fill or an order — those are a coordinator's actions on the Web. + +/** A navigation row on the Records home: icon, title, a line of context and an optional count. */ +export const RecordsEntryRow: React.FC<{ icon: React.ReactNode; title: string; subtitle?: string; count?: number; attention?: boolean; onPress: () => void; testID?: string }> = ({ + icon, + title, + subtitle, + count, + attention, + onPress, + testID, +}) => ( + + + {icon} + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {count !== undefined && count > 0 ? ( + + {count} + + ) : null} + + + +); + +export const DeploymentListItem: React.FC<{ deployment: RecordDeploymentData; conflicts?: number; onPress: () => void; testID?: string }> = ({ deployment, conflicts, onPress, testID }) => { + const { t } = useTranslation(); + const connectorOwned = isConnectorOwned(deployment); + + return ( + + + {connectorOwned ? : } + + + {deployment.OrderNumber} + + + {deployment.IncidentName} + {deployment.IncidentNumber ? ` · ${deployment.IncidentNumber}` : ''} + + + {deployment.SourceScheme} + {deployment.SourceSystem ? ` · ${deployment.SourceSystem}` : ''} + {connectorOwned ? ` · ${t('records.connector_maintained')}` : ''} + + + + + {t(`records.deployment_status_${statusKey(deployment.Status)}`, { defaultValue: deployment.Status })} + + {conflicts ? ( + + {t('records.reconciliation_count', { count: conflicts })} + + ) : null} + + + + ); +}; + +export const DeploymentFillItem: React.FC<{ fill: RecordDeploymentFillData; testID?: string }> = ({ fill, testID }) => { + const { t } = useTranslation(); + const what = [fill.Position, fill.ResourceType, fill.ResourceKind].filter(Boolean).join(' · '); + const when = fill.NeededOn ? formatWhen(fill.NeededOn) : ''; + + return ( + + + + + {fill.RequestNumber} + {fill.RequestCategory ? ` · ${fill.RequestCategory}` : ''} + {fill.IsTrainee ? ` · ${t('records.trainee')}` : ''} + + {what ? ( + + {what} + + ) : null} + {fill.HomeUnit || when ? ( + + {[fill.HomeUnit, when ? `${t('records.needed_on')} ${when}` : null].filter(Boolean).join(' · ')} + + ) : null} + {fill.DeclineReason ? ( + + {fill.DeclineReason} + + ) : null} + + + {t(`records.fill_status_${statusKey(fill.Status)}`, { defaultValue: fill.Status })} + + + + ); +}; + +/** One disagreement between the source and the department's record. Shown, never applied. */ +export const ReconciliationListItem: React.FC<{ item: RecordDeploymentReconciliationData; showOrder?: boolean; onPress?: () => void; testID?: string }> = ({ item, showOrder, onPress, testID }) => { + const { t } = useTranslation(); + const body = ( + + + + + + + {t(`records.reconciliation_${item.Kind}`, { defaultValue: item.Kind })} + + + {[showOrder ? item.OrderNumber : null, item.RequestNumber].filter(Boolean).join(' · ')} + + + {t('records.source_says')} {item.SourceStatus ?? '—'} · {t('records.your_record_says')} {item.LocalStatus ?? '—'} + + {item.SourceCapturedOn ? ( + + {formatWhen(item.SourceCapturedOn)} + {item.SourceVersion ? ` · v${item.SourceVersion}` : ''} + + ) : null} + + {onPress ? : null} + + ); + + return onPress ? ( + + {body} + + ) : ( + + {body} + + ); +}; + +export const ConnectorListItem: React.FC<{ connector: RecordDeploymentConnectorData; conflicts?: number; onPress: () => void; testID?: string }> = ({ connector, conflicts, onPress, testID }) => { + const { t } = useTranslation(); + + return ( + + + + + + + + {connector.Name} + + + {connector.ProviderKey} · {connector.SourceScheme} · {connector.ProfileKey} + + + {connector.LastPolledOn ? `${t('records.last_read')} ${formatWhen(connector.LastPolledOn)}` : t('records.never_read')} + + {connector.LastError ? ( + + {connector.LastError} + + ) : null} + + + + {connector.IsReadyToRun ? t('records.connector_ready') : connector.IsEnabled ? t('records.connector_not_ready') : t('records.connector_disabled')} + + {conflicts ? ( + + {t('records.reconciliation_count', { count: conflicts })} + + ) : null} + + + + ); +}; + +export const ConnectorRunItem: React.FC<{ run: RecordDeploymentConnectorRunData; testID?: string }> = ({ run, testID }) => { + const { t } = useTranslation(); + + return ( + + + + + {formatWhen(run.StartedOn)} · {t(`records.run_trigger_${run.Trigger}`, { defaultValue: run.Trigger })} + + + {t('records.run_counts', { + seen: run.OrdersSeen, + created: run.OrdersCreated, + snapshots: run.SnapshotsRecorded, + requests: run.RequestsAdded, + unchanged: run.Unchanged, + rejected: run.Rejected, + conflicts: run.Conflicts, + })} + + {run.Error ? ( + + {run.Error} + + ) : null} + + + {t(`records.run_outcome_${run.Outcome}`, { defaultValue: run.Outcome })} + + + + ); +}; diff --git a/src/components/records/record-attachments.tsx b/src/components/records/record-attachments.tsx new file mode 100644 index 00000000..5989fbe2 --- /dev/null +++ b/src/components/records/record-attachments.tsx @@ -0,0 +1,266 @@ +import * as DocumentPicker from 'expo-document-picker'; +import * as ImagePicker from 'expo-image-picker'; +import { Camera, FileUp, Images, MapPin, RefreshCw, Trash2, TriangleAlert } from 'lucide-react-native'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Platform } from 'react-native'; + +import { getRecordAttachments, type RecordAttachmentData, removeRecordAttachment } from '@/api/records/record-uploads'; +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Divider } from '@/components/ui/divider'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Progress, ProgressFilledTrack } from '@/components/ui/progress'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { logger } from '@/lib/logging'; +import { fileSize, hashFile } from '@/lib/records/uploads'; +import { useRecordsStore } from '@/stores/records/store'; + +// Attachment capture for a Record (RMS plan RMS-1D). Capture from the camera, the library or a file, +// then upload in chunks against a server-owned session so an interruption resumes rather than +// restarts. What happened to a photo's location metadata is shown on the attachment, not implied. + +interface RecordAttachmentsProps { + recordId: string; + /** From the catalog entry; false hides capture entirely rather than failing at upload. */ + allowAttachments: boolean; + readOnly?: boolean; +} + +const localId = () => `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +const guessContentType = (name: string, fallback?: string | null): string => { + const extension = (name.split('.').pop() ?? '').toLowerCase(); + switch (extension) { + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'png': + return 'image/png'; + case 'heic': + return 'image/heic'; + case 'pdf': + return 'application/pdf'; + default: + return fallback && fallback.length > 0 ? fallback : 'application/octet-stream'; + } +}; + +export const RecordAttachments: React.FC = ({ recordId, allowAttachments, readOnly }) => { + const { t } = useTranslation(); + const pendingUploads = useRecordsStore((state) => state.pendingUploads); + const stageUpload = useRecordsStore((state) => state.stageUpload); + const runUploads = useRecordsStore((state) => state.runUploads); + const retryUpload = useRecordsStore((state) => state.retryUpload); + const discardUpload = useRecordsStore((state) => state.discardUpload); + const uploadProgress = useRecordsStore((state) => state.uploadProgress); + + const [attachments, setAttachments] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [message, setMessage] = useState(null); + + const pending = Object.values(pendingUploads).filter((upload) => upload.recordId === recordId); + + const load = useCallback(async () => { + setIsLoading(true); + try { + const response = await getRecordAttachments(recordId); + setAttachments(response?.Data ?? []); + } catch (error) { + logger.error({ message: 'Record attachments could not be listed', context: { error, recordId } }); + } finally { + setIsLoading(false); + } + }, [recordId]); + + useEffect(() => { + void load(); + }, [load]); + + const stage = useCallback( + async (uri: string, name: string, contentType: string) => { + setMessage(null); + try { + const size = await fileSize(uri); + if (size <= 0) { + setMessage(t('records.attachment_unreadable')); + return; + } + const sha256 = await hashFile(uri); + stageUpload({ + localId: localId(), + recordId, + uploadId: null, + fileUri: uri, + fileName: name, + contentType, + byteSize: size, + sha256, + sentBytes: 0, + classification: 1, + createdOn: new Date().toISOString(), + }); + const outcome = await runUploads(recordId); + if (outcome.uploaded > 0) { + await load(); + } + if (outcome.failed > 0) { + setMessage(t('records.attachment_failed')); + } + } catch (error) { + logger.error({ message: 'Record attachment could not be staged', context: { error, recordId } }); + setMessage(t('records.attachment_failed')); + } + }, + [recordId, stageUpload, runUploads, load, t] + ); + + const pickFromCamera = useCallback(async () => { + const permission = await ImagePicker.requestCameraPermissionsAsync(); + if (!permission.granted) { + setMessage(t('records.camera_denied')); + return; + } + const result = await ImagePicker.launchCameraAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: false, quality: 0.8, exif: false }); + const asset = result.canceled ? null : result.assets?.[0]; + if (asset?.uri) { + await stage(asset.uri, asset.fileName ?? `photo-${Date.now()}.jpg`, guessContentType(asset.fileName ?? '.jpg', asset.mimeType)); + } + }, [stage, t]); + + const pickFromLibrary = useCallback(async () => { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: false, + quality: 0.8, + exif: false, + ...(Platform.OS === 'ios' && { preferredAssetRepresentationMode: ImagePicker.UIImagePickerPreferredAssetRepresentationMode.Compatible }), + }); + const asset = result.canceled ? null : result.assets?.[0]; + if (asset?.uri) { + await stage(asset.uri, asset.fileName ?? `image-${Date.now()}.jpg`, guessContentType(asset.fileName ?? '.jpg', asset.mimeType)); + } + }, [stage]); + + const pickFile = useCallback(async () => { + const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false }); + const asset = result.canceled ? null : result.assets?.[0]; + if (asset?.uri) { + await stage(asset.uri, asset.name ?? `file-${Date.now()}`, guessContentType(asset.name ?? '', asset.mimeType)); + } + }, [stage]); + + const remove = useCallback( + async (attachmentId: string) => { + try { + await removeRecordAttachment(recordId, attachmentId); + await load(); + } catch (error) { + logger.error({ message: 'Record attachment could not be removed', context: { error, recordId, attachmentId } }); + setMessage(t('records.attachment_failed')); + } + }, + [recordId, load, t] + ); + + if (!allowAttachments) { + return null; + } + + return ( + + + {t('records.attachments')} + {isLoading ? : null} + + + {message ? {message} : null} + + {attachments.length === 0 && pending.length === 0 ? {t('records.no_attachments')} : null} + + {attachments.map((attachment) => ( + + + + + {attachment.FileName} + + + {/* The person is told what happened to the photo's location rather than left to guess. */} + {attachment.MediaLocationRetained ? ( + + + {t('records.location_kept')} + + ) : attachment.MetadataStripped ? ( + + {t('records.location_removed')} + + ) : null} + + + {!readOnly ? ( + void remove(attachment.AttachmentId)} testID={`record-attachment-remove-${attachment.AttachmentId}`}> + + + ) : null} + + + + ))} + + {pending.map((upload) => { + const progress = uploadProgress[upload.localId] ?? upload.sentBytes; + const percent = upload.byteSize > 0 ? Math.min(100, Math.round((progress / upload.byteSize) * 100)) : 0; + return ( + + + + + {upload.fileName} + + {upload.isUnrecoverable ? t('records.attachment_file_missing') : upload.lastError ? t('records.attachment_will_resume') : `${percent}%`} + + {upload.lastError && !upload.isUnrecoverable ? ( + void retryUpload(upload.localId)} testID={`record-upload-retry-${upload.localId}`}> + + + ) : null} + {upload.isUnrecoverable ? : null} + void discardUpload(upload.localId)} testID={`record-upload-discard-${upload.localId}`}> + + + + {!upload.isUnrecoverable ? ( + + + + ) : null} + + ); + })} + + {!readOnly ? ( + + + + + + ) : null} + + ); +}; diff --git a/src/components/records/record-field.tsx b/src/components/records/record-field.tsx new file mode 100644 index 00000000..9a4436af --- /dev/null +++ b/src/components/records/record-field.tsx @@ -0,0 +1,188 @@ +import { Lock } from 'lucide-react-native'; +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Checkbox, CheckboxIcon, CheckboxIndicator, CheckboxLabel } from '@/components/ui/checkbox'; +import { HStack } from '@/components/ui/hstack'; +import { Icon } from '@/components/ui/icon'; +import { Input, InputField } from '@/components/ui/input'; +import { Select, SelectBackdrop, SelectContent, SelectDragIndicator, SelectDragIndicatorWrapper, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select'; +import { Text } from '@/components/ui/text'; +import { Textarea, TextareaInput } from '@/components/ui/textarea'; +import { VStack } from '@/components/ui/vstack'; +import { isRestricted, isSupportedField } from '@/lib/records/schema'; +import { type RecordFieldSchema, type RecordValueInput, RmsFieldType } from '@/models/v4/records'; + +// One control from the definition schema. A field type this build cannot draw renders read-only with +// a plain explanation instead of guessing at an editor — the Web app remains the escape path. + +interface RecordFieldProps { + field: RecordFieldSchema; + value: RecordValueInput | undefined; + required: boolean; + disabled?: boolean; + invalid?: boolean; + onChange: (next: Partial) => void; + testID?: string; +} + +const keyboardFor = (type: RmsFieldType): 'default' | 'numeric' | 'decimal-pad' => { + if (type === RmsFieldType.Integer) { + return 'numeric'; + } + if (type === RmsFieldType.Decimal || type === RmsFieldType.Currency || type === RmsFieldType.Quantity) { + return 'decimal-pad'; + } + return 'default'; +}; + +export const RecordField: React.FC = ({ field, value, required, disabled, invalid, onChange, testID }) => { + const { t } = useTranslation(); + const text = value?.Value ?? ''; + const selected = value?.Values ?? []; + + const setText = useCallback((next: string) => onChange({ Value: next }), [onChange]); + + const label = ( + + {field.Label} + {required ? * : null} + {isRestricted(field) ? ( + + + {t('records.restricted')} + + ) : null} + + ); + + const wrap = (control: React.ReactNode) => ( + + {label} + {control} + {field.Help ? {field.Help} : null} + {invalid ? {t('records.field_required')} : null} + + ); + + if (!isSupportedField(field)) { + // Fail closed: this build cannot represent the field, so it never pretends to author it. + return wrap( + + {t('records.unsupported_field')} + + ); + } + + switch (field.Type) { + case RmsFieldType.LongText: + return wrap( + + ); + + case RmsFieldType.Boolean: + return wrap( + onChange({ Value: checked ? 'true' : 'false' })}> + + + + {field.Label} + + ); + + case RmsFieldType.SingleSelect: + return wrap( + + ); + + case RmsFieldType.MultiSelect: + return wrap( + + {(field.Options ?? []) + .filter((option) => !option.IsInactive) + .map((option) => { + const checked = selected.includes(option.Key); + return ( + onChange({ Values: next ? [...selected, option.Key] : selected.filter((entry) => entry !== option.Key) })} + > + + + + {option.Label} + + ); + })} + + ); + + case RmsFieldType.Attachment: + // Attachments upload through the resumable session on the record screen, not inline here. + return wrap( + + {t('records.attachments_hint')} + + ); + + case RmsFieldType.Signature: + return wrap( + 0} isDisabled={disabled} onChange={(checked) => onChange({ Value: checked ? new Date().toISOString() : '' })}> + + + + {t('records.sign_statement')} + + ); + + case RmsFieldType.Quantity: + return wrap( + + + + + + onChange({ UnitCode: next })} placeholder={t('records.unit_code')} /> + + + ); + + default: + return wrap( + + + + ); + } +}; diff --git a/src/components/records/record-form.tsx b/src/components/records/record-form.tsx new file mode 100644 index 00000000..09064ed1 --- /dev/null +++ b/src/components/records/record-form.tsx @@ -0,0 +1,150 @@ +import { Plus, Trash2 } from 'lucide-react-native'; +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { RecordField } from '@/components/records/record-field'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Divider } from '@/components/ui/divider'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { cellKey, evaluateRules, isFieldHidden, isFieldRequired, rowKeysFor, type ValidationIssue, type ValueMap } from '@/lib/records/schema'; +import { type RecordDefinitionSchema, type RecordValueInput } from '@/models/v4/records'; + +// Section-card rendering of one definition version. Rules are evaluated on every change, per row +// inside repeating sections, so what the person sees matches what the server will accept. + +interface RecordFormProps { + schema: RecordDefinitionSchema; + values: ValueMap; + issues?: ValidationIssue[]; + readOnly?: boolean; + forFinalize?: boolean; + onChange: (values: ValueMap) => void; +} + +const newRowKey = () => `row-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +export const RecordForm: React.FC = ({ schema, values, issues, readOnly, forFinalize, onChange }) => { + const { t } = useTranslation(); + const rules = useMemo(() => evaluateRules(schema, values), [schema, values]); + const invalidKeys = useMemo(() => new Set((issues ?? []).map((issue) => cellKey(issue.sectionKey, issue.fieldKey, issue.rowKey))), [issues]); + + const setValue = useCallback( + (sectionKey: string, fieldKey: string, rowKey: string | null, patch: Partial) => { + const key = cellKey(sectionKey, fieldKey, rowKey); + const existing = values[key] ?? { SectionKey: sectionKey, FieldKey: fieldKey, RowKey: rowKey }; + onChange({ ...values, [key]: { ...existing, ...patch } }); + }, + [values, onChange] + ); + + const addRow = useCallback( + (sectionKey: string, fieldKeys: string[]) => { + const rowKey = newRowKey(); + const ordinal = rowKeysFor(values, sectionKey).length; + const next: ValueMap = { ...values }; + for (const fieldKey of fieldKeys) { + next[cellKey(sectionKey, fieldKey, rowKey)] = { SectionKey: sectionKey, FieldKey: fieldKey, RowKey: rowKey, Ordinal: ordinal }; + } + onChange(next); + }, + [values, onChange] + ); + + const removeRow = useCallback( + (sectionKey: string, rowKey: string) => { + const next: ValueMap = {}; + for (const [key, value] of Object.entries(values)) { + if (value.SectionKey === sectionKey && value.RowKey === rowKey) { + continue; + } + next[key] = value; + } + onChange(next); + }, + [values, onChange] + ); + + return ( + + {(schema.Sections ?? []).map((section) => { + if (rules.hiddenSectionKeys.has(section.Key)) { + return null; + } + const fieldKeys = (section.Fields ?? []).map((field) => field.Key); + const rowKeys = section.Repeating ? rowKeysFor(values, section.Key) : []; + const canAddRow = !readOnly && (!section.MaxRows || rowKeys.length < section.MaxRows); + + return ( + + + {section.Label} + {section.Help ? {section.Help} : null} + + {section.Repeating ? ( + + {rowKeys.length === 0 ? {t('records.no_rows')} : null} + {rowKeys.map((rowKey, index) => ( + + + {`${section.Label} ${index + 1}`} + {!readOnly ? ( + removeRow(section.Key, rowKey)} testID={`records-remove-row-${section.Key}-${index}`}> + + + ) : null} + + {(section.Fields ?? []).map((field) => + isFieldHidden(rules, section.Key, field.Key, rowKey) ? null : ( + setValue(section.Key, field.Key, rowKey, patch)} + testID={`records-field-${section.Key}-${index}-${field.Key}`} + /> + ) + )} + + ))} + {canAddRow ? ( + + ) : null} + + ) : ( + + + {(section.Fields ?? []).map((field) => + isFieldHidden(rules, section.Key, field.Key, null) ? null : ( + setValue(section.Key, field.Key, null, patch)} + testID={`records-field-${section.Key}-${field.Key}`} + /> + ) + )} + + )} + + + ); + })} + + ); +}; diff --git a/src/components/records/record-list-item.tsx b/src/components/records/record-list-item.tsx new file mode 100644 index 00000000..01803cd0 --- /dev/null +++ b/src/components/records/record-list-item.tsx @@ -0,0 +1,101 @@ +import { AlertTriangle, CloudOff, FileText } from 'lucide-react-native'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { type FieldRecordAssignmentData, type RecordSummaryData, RmsRecordState } from '@/models/v4/records'; +import { type PendingRecordDraft } from '@/stores/records/store'; + +const stateAction = (state: number): 'muted' | 'info' | 'warning' | 'success' | 'error' => { + switch (state) { + case RmsRecordState.Draft: + return 'muted'; + case RmsRecordState.ReadyForReview: + return 'info'; + case RmsRecordState.Returned: + return 'warning'; + case RmsRecordState.Finalized: + case RmsRecordState.Amended: + return 'success'; + default: + return 'error'; + } +}; + +export const RecordListItem: React.FC<{ record: RecordSummaryData; onPress: () => void; testID?: string }> = ({ record, onPress, testID }) => { + const { t } = useTranslation(); + const title = record.RecordNumber || record.DraftReference || record.DefinitionKey || t('records.untitled'); + + return ( + + + + + + + + {title} + + + {record.DisplaySummary || record.DefinitionKey || ''} + + + + {record.StateName ?? RmsRecordState[record.State] ?? ''} + + + + ); +}; + +export const PendingDraftItem: React.FC<{ draft: PendingRecordDraft; onPress: () => void; testID?: string }> = ({ draft, onPress, testID }) => { + const { t } = useTranslation(); + + return ( + + + {draft.conflict ? : } + + + {draft.name} + + + {draft.conflict ? t(`records.conflict_${draft.conflict.replace('-', '_')}`) : t('records.not_sent_yet')} + + + + {draft.conflict ? t('records.needs_attention') : t('records.pending')} + + + + ); +}; + +export const AssignmentItem: React.FC<{ assignment: FieldRecordAssignmentData; onPress: () => void; testID?: string }> = ({ assignment, onPress, testID }) => { + const { t } = useTranslation(); + + return ( + + + + + {t(`records.purpose_${assignment.Purpose}`, { defaultValue: assignment.Purpose })} + + {assignment.Note ? ( + + {assignment.Note} + + ) : null} + + + {assignment.State} + + + + ); +}; diff --git a/src/components/records/records-quick-create.tsx b/src/components/records/records-quick-create.tsx new file mode 100644 index 00000000..ef53f910 --- /dev/null +++ b/src/components/records/records-quick-create.tsx @@ -0,0 +1,48 @@ +import { router } from 'expo-router'; +import { FilePlus2 } from 'lucide-react-native'; +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { type FieldRecordContextInput } from '@/models/v4/records'; +import { useRecordsFieldStatus } from '@/stores/feature-flags/store'; +import { useRecordsStore } from '@/stores/records/store'; + +// Contextual create (RMS plan RMS-1D): start a Record from the thing the person is already looking +// at. The button appears only when the server has actually offered something for this context, so a +// dead end is impossible; the context itself is verified server-side on every request. + +interface RecordsQuickCreateProps { + context: FieldRecordContextInput; + size?: 'xs' | 'sm' | 'md' | 'lg'; + variant?: 'solid' | 'outline' | 'link'; + className?: string; + testID?: string; +} + +export const RecordsQuickCreate: React.FC = ({ context, size = 'sm', variant = 'outline', className, testID = 'records-quick-create' }) => { + const { t } = useTranslation(); + const flagStatus = useRecordsFieldStatus(); + const setContext = useRecordsStore((state) => state.setContext); + const fetchCatalog = useRecordsStore((state) => state.fetchCatalog); + const catalog = useRecordsStore((state) => state.catalog); + + useEffect(() => { + if (flagStatus !== 'enabled') { + return; + } + setContext(context); + void fetchCatalog(); + }, [flagStatus, context, setContext, fetchCatalog]); + + if (flagStatus !== 'enabled' || (catalog?.Definitions?.length ?? 0) === 0) { + return null; + } + + return ( + + ); +}; diff --git a/src/components/sidebar/sidebar-content.tsx b/src/components/sidebar/sidebar-content.tsx index 898f0d38..b3b15bc0 100644 --- a/src/components/sidebar/sidebar-content.tsx +++ b/src/components/sidebar/sidebar-content.tsx @@ -1,5 +1,5 @@ import { useRouter } from 'expo-router'; -import { MessagesSquare, Settings, Sparkles } from 'lucide-react-native'; +import { Briefcase, FileText, MessagesSquare, Package, Settings, Sparkles } from 'lucide-react-native'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView } from 'react-native'; @@ -9,7 +9,7 @@ import { HStack } from '@/components/ui/hstack'; import { VStack } from '@/components/ui/vstack'; import { invertColor } from '@/lib/utils'; import { useCoreStore } from '@/stores/app/core-store'; -import { useIsChatEnabled } from '@/stores/feature-flags/store'; +import { useIsChatEnabled, useIsChecklistsEnabled, useIsDeploymentsEnabled, useIsRecordsFieldEnabled } from '@/stores/feature-flags/store'; import { useStatusBottomSheetStore } from '@/stores/status/store'; import ZeroState from '../common/zero-state'; @@ -27,6 +27,10 @@ const Sidebar = ({ onClose }: SidebarProps) => { const activeStatuses = useCoreStore((state) => state.activeStatuses); const setIsOpen = useStatusBottomSheetStore((state) => state.setIsOpen); const isChatEnabled = useIsChatEnabled(); + const isChecklistsEnabled = useIsChecklistsEnabled(); + const isDeploymentsEnabled = useIsDeploymentsEnabled(); + const hasActiveUnit = !!useCoreStore((state) => state.activeUnitId); + const isRecordsEnabled = useIsRecordsFieldEnabled(); const { t } = useTranslation(); const router = useRouter(); @@ -42,6 +46,11 @@ const Sidebar = ({ onClose }: SidebarProps) => { router.push('/chat'); }; + const handleNavigateToRecords = () => { + onClose?.(); + router.push('/records'); + }; + const handleNavigateToAssistant = () => { onClose?.(); router.push('/chatbot'); @@ -79,6 +88,61 @@ const Sidebar = ({ onClose }: SidebarProps) => { ) : null} + {isChecklistsEnabled ? ( + + ) : null} + + {/* Deployments / daily time reports (hidden until Operations.Deployments is on) */} + {isDeploymentsEnabled ? ( + + ) : null} + + {/* The active unit's inventory: what it carries and the counts the crew runs (needs an active unit) */} + {hasActiveUnit ? ( + + ) : null} + + {/* Field Records (hidden until Records.System and this app's child flag are both on) */} + {isRecordsEnabled ? ( + + ) : null} + {/* Third row - Status buttons or empty state */} {isActiveStatusesEmpty ? ( { expect(screen.getByText('Step 2 of 2')).toBeTruthy(); expect(screen.getByText('Add Note')).toBeTruthy(); - expect(screen.getByText('Selected Destination:')).toBeTruthy(); - expect(screen.getByText('No Destination')).toBeTruthy(); + // The note step is the last step: a one-line summary replaces the old review blocks + expect(screen.getByTestId('status-summary').props.children).toBe('Available · No Destination'); + expect(screen.queryByText('Selected Destination:')).toBeNull(); expect(screen.getByText('Previous')).toBeTruthy(); expect(screen.getByText('Submit')).toBeTruthy(); }); @@ -1086,9 +1087,9 @@ describe('StatusBottomSheet', () => { const selectedStatus = { Id: 'status-1', - Text: 'Available', - Detail: 0, // No destination step needed - Note: 0, // No note required + Text: 'Responding', + Detail: 2, // Calls — a selection only counts for a status that offers it + Note: 0, // No note: the destination step submits }; // Mock core store with no active call @@ -1136,9 +1137,9 @@ describe('StatusBottomSheet', () => { const selectedStatus = { Id: 'status-1', - Text: 'Available', - Detail: 0, // No destination step needed - Note: 0, // No note required + Text: 'Responding', + Detail: 2, // Calls — a selection only counts for a status that offers it + Note: 0, // No note: the destination step submits }; // Mock core store with different active call @@ -1186,9 +1187,9 @@ describe('StatusBottomSheet', () => { const selectedStatus = { Id: 'status-1', - Text: 'Available', - Detail: 0, // No destination step needed - Note: 0, // No note required + Text: 'Responding', + Detail: 2, // Calls — a selection only counts for a status that offers it + Note: 0, // No note: the destination step submits }; // Mock core store with same active call @@ -1268,9 +1269,9 @@ describe('StatusBottomSheet', () => { const selectedStatus = { Id: 'status-1', - Text: 'Available', - Detail: 0, // No destination step needed - Note: 0, // No note required + Text: 'At Station', + Detail: 1, // Stations + Note: 0, // No note: the destination step submits }; mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { @@ -1347,12 +1348,13 @@ describe('StatusBottomSheet', () => { expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('call'); expect(mockSetActiveCall).not.toHaveBeenCalled(); - // Update mock store to reflect call selection + // Re-render to reflect the call selection mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { const store = { ...mockStore, selectedCall: mockCall, selectedDestinationType: 'call', + currentStep: 'select-destination', }; if (selector) { return selector(store); @@ -1360,31 +1362,13 @@ describe('StatusBottomSheet', () => { return store; }); - // Step 2: Navigate to next step - const nextButton = screen.getByText('Next'); - fireEvent.press(nextButton); + render(); // setActiveCall should still NOT have been called expect(mockSetActiveCall).not.toHaveBeenCalled(); - // Re-render to show the final step (submit) - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...mockStore, - selectedCall: mockCall, - selectedDestinationType: 'call', - currentStep: 'select-destination', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Step 3: Submit - NOW setActiveCall should be called - const submitButtonAfterFlow = screen.getByText('Next'); // This should trigger submit for no note status + // Step 2: No note, so the destination step is the last step and submits - NOW setActiveCall should be called + const submitButtonAfterFlow = screen.getByText('Submit'); fireEvent.press(submitButtonAfterFlow); await waitFor(() => { @@ -2691,8 +2675,12 @@ describe('StatusBottomSheet', () => { render(); - const nextButton = screen.getByText('Next'); - fireEvent.press(nextButton); + // Nothing else to collect, so status selection is the last step and carries Submit + expect(screen.queryByText('Next')).toBeNull(); + expect(screen.getByTestId('status-summary').props.children).toBe('Available · No Destination'); + + const submitButton = screen.getByText('Submit'); + fireEvent.press(submitButton); // Submitting now takes a location fix first, so the save lands a microtask later. await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalled()); @@ -3091,7 +3079,7 @@ describe('StatusBottomSheet', () => { expect(screen.getByText('Next')).toBeTruthy(); }); - it('should maintain Next button visibility with many calls and stations in Committed status', async () => { + it('should maintain Submit button visibility with many calls and stations in Committed status', async () => { const committedStatus = { Id: 'committed-status', Text: 'Committed', @@ -3136,15 +3124,14 @@ describe('StatusBottomSheet', () => { expect(screen.getByText('C001 - Emergency Call 1')).toBeTruthy(); expect(screen.getByText('C005 - Emergency Call 5')).toBeTruthy(); - // Next button should still be visible and functional - const nextButton = screen.getByText('Next'); - expect(nextButton).toBeTruthy(); + // No note, so this is the last step: Submit (not Next) should be visible and functional + const submitButton = screen.getByText('Submit'); + expect(submitButton).toBeTruthy(); - // Should be able to click Next button without scrolling - fireEvent.press(nextButton); + // Should be able to click Submit button without scrolling + fireEvent.press(submitButton); - // Since no note is required, this should trigger submit. The save lands a microtask later now - // that submitting takes a location fix first. + // The save lands a microtask later now that submitting takes a location fix first. await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalled()); }); @@ -3297,10 +3284,9 @@ describe('StatusBottomSheet', () => { render(); - expect(screen.getByText('Selected Status:')).toBeTruthy(); - expect(screen.getByText('Available')).toBeTruthy(); - expect(screen.getByText('Selected Destination:')).toBeTruthy(); - expect(screen.getByText('C123 - Emergency Call')).toBeTruthy(); + const summary = screen.getByTestId('status-summary'); + expect(summary.props.children).toBe('Available · C123 - Emergency Call'); + expect(summary.props.accessibilityLabel).toBe('Selected Status: Available, Selected Destination: C123 - Emergency Call'); }); it('should disable submit button and show spinner when submitting', async () => { @@ -3589,8 +3575,7 @@ describe('StatusBottomSheet', () => { render(); - expect(screen.getByText('Selected Destination:')).toBeTruthy(); - expect(screen.getByText('No Destination')).toBeTruthy(); + expect(screen.getByTestId('status-summary').props.children).toBe('Available · No Destination'); }); it('should show loading state when call is being selected but selectedCall is null', () => { @@ -3643,8 +3628,8 @@ describe('StatusBottomSheet', () => { render(); - expect(screen.getByText('Selected Destination:')).toBeTruthy(); - expect(screen.getByText('C123 - Emergency Call')).toBeTruthy(); // Should find the call from availableCalls + // Should find the call from availableCalls + expect(screen.getByTestId('status-summary').props.children).toBe('Available · C123 - Emergency Call'); }); it('should show loading text when call type is selected but no calls are available yet', () => { @@ -3679,6 +3664,7 @@ describe('StatusBottomSheet', () => { selectedDestinationType: 'call', // Set to call but no selectedCall yet selectedCall: null, // This is the issue scenario availableCalls: [], // No calls available yet + isLoading: true, // The destination list is still on its way }; if (selector) { return selector(store); @@ -3688,8 +3674,16 @@ describe('StatusBottomSheet', () => { render(); - expect(screen.getByText('Selected Destination:')).toBeTruthy(); - expect(screen.getByText('Loading calls...')).toBeTruthy(); // Should show loading text + // Should show loading text, and not let the status be saved without its call + expect(screen.getByTestId('status-summary').props.children).toBe('Available · Loading calls...'); + const submitButton = screen.getAllByTestId('button').find((button) => { + try { + return button.findAllByType('Text' as any).some((text: any) => text.props.children === 'Submit'); + } catch (e) { + return false; + } + }); + expect(submitButton?.props.accessibilityState?.disabled).toBe(true); }); // New tests for color scheme functionality @@ -3772,8 +3766,7 @@ describe('StatusBottomSheet', () => { render(); - expect(screen.getByText('Selected Status:')).toBeTruthy(); - expect(screen.getByText('Responding')).toBeTruthy(); + expect(screen.getByTestId('status-summary').props.children).toBe('Responding · No Destination'); // The status display should use BColor for background and calculated text color // We can't easily test the actual computed styles, but we verify rendering works @@ -3827,7 +3820,7 @@ describe('StatusBottomSheet', () => { // Component should handle missing BColor gracefully with fallback }); - it('should show Next button when tabs are visible with reduced ScrollView height', () => { + it('should show Submit button when tabs are visible with reduced ScrollView height', () => { const committedStatus = { Id: 4, Text: 'Committed', @@ -3876,12 +3869,12 @@ describe('StatusBottomSheet', () => { // Should show some calls (even with many items) expect(screen.getByText('C-000 - Emergency Call 0')).toBeTruthy(); - // Next button should still be visible and accessible - const nextButton = screen.getByText('Next'); - expect(nextButton).toBeTruthy(); + // No note, so the last-step Submit button should still be visible and accessible + const submitButton = screen.getByText('Submit'); + expect(submitButton).toBeTruthy(); // Button should be enabled (can proceed) - fireEvent.press(nextButton); + fireEvent.press(submitButton); // Should not throw or fail to find the button }); @@ -3917,7 +3910,7 @@ describe('StatusBottomSheet', () => { buildGpsRequiredStore(gpsRequiredStatus); render(); - fireEvent.press(screen.getByText('Next')); + fireEvent.press(screen.getByText('Submit')); await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('error', `fix-error:${outcome}`)); expect(mockSaveUnitStatus).not.toHaveBeenCalled(); @@ -3928,7 +3921,7 @@ describe('StatusBottomSheet', () => { buildGpsRequiredStore(gpsRequiredStatus); render(); - fireEvent.press(screen.getByText('Next')); + fireEvent.press(screen.getByText('Submit')); await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalled()); expect(mockSaveUnitStatus).toHaveBeenCalledWith(expect.objectContaining({ Latitude: '40.7128', Longitude: '-74.006' })); @@ -3939,9 +3932,270 @@ describe('StatusBottomSheet', () => { buildGpsRequiredStore({ ...gpsRequiredStatus, Gps: false }); render(); - fireEvent.press(screen.getByText('Next')); + fireEvent.press(screen.getByText('Submit')); await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalled()); }); }); + + // Call reports link a unit status to a call only through RespondingTo/RespondingToType, so these + // pin down which call (if any) a save carries and that the crew can always see and override it. + describe('call linkage and last-step submit', () => { + const dispatchedCall = { CallId: '555', Number: 'C555', Name: 'Structure Fire', Address: '1 Main St' }; + const otherCall = { CallId: '777', Number: 'C777', Name: 'Medical Aid', Address: '2 Main St' }; + + const respondingStatus = { Id: 2, Text: 'Responding', BColor: '#ffc107', Gps: false, Detail: 2, Note: 0 }; + const onSceneNoDestinationStatus = { Id: 3, Text: 'On Scene', BColor: '#dc3545', Gps: false, Detail: 0, Note: 0 }; + + // What dispatch leaves behind: a "Responding" status for the unit pointing at the dispatched call. + const dispatchedUnitStatus = { UnitId: 'unit-1', State: 'Responding', DestinationId: 555, DestinationType: 2 }; + + const setCoreStore = (overrides: Record) => { + const store = { ...defaultCoreStore, ...overrides }; + mockGetState.mockReturnValue(store as any); + mockUseCoreStore.mockImplementation((selector: any) => (selector ? selector(store) : store)); + }; + + const setSheetStore = (overrides: Record) => { + const store = { ...defaultBottomSheetStore, isOpen: true, ...overrides }; + mockUseStatusBottomSheetStore.mockImplementation((selector: any) => (selector ? selector(store) : store)); + }; + + // Setters that actually update the mocked state, so effects see the result of a tap on re-render. + const mountStatefulSheetStore = (overrides: Record) => { + let state: any = { ...defaultBottomSheetStore, isOpen: true, ...overrides }; + const update = (patch: Record) => { + state = { ...state, ...patch }; + }; + state.setSelectedCall = jest.fn((selectedCall) => update({ selectedCall })); + state.setSelectedStation = jest.fn((selectedStation) => update({ selectedStation })); + state.setSelectedPoi = jest.fn((selectedPoi) => update({ selectedPoi })); + state.setSelectedDestinationType = jest.fn((selectedDestinationType) => update({ selectedDestinationType })); + mockUseStatusBottomSheetStore.mockImplementation((selector: any) => (selector ? selector(state) : state)); + return state; + }; + + const findButton = (label: string) => + screen.getAllByTestId('button').find((button) => { + try { + return button.findAllByType('Text' as any).some((text: any) => text.props.children === label); + } catch (e) { + return false; + } + }); + + const findRowClassName = (label: string): string | undefined => { + let node: any = screen.getByText(label); + while (node && !(typeof node.props?.className === 'string' && node.props.className.includes('border-2'))) { + node = node.parent; + } + return node?.props.className; + }; + + it('submits from the destination step when the status takes no note (no review step)', async () => { + setSheetStore({ selectedStatus: respondingStatus, availableCalls: [dispatchedCall], selectedCall: dispatchedCall, selectedDestinationType: 'call' }); + + render(); + + expect(screen.getByText('Step 1 of 1')).toBeTruthy(); + expect(screen.queryByText('Next')).toBeNull(); + expect(screen.getByTestId('status-summary').props.children).toBe('Responding · C555 - Structure Fire'); + + fireEvent.press(screen.getByText('Submit')); + + await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalledWith(expect.objectContaining({ RespondingTo: '555', RespondingToType: 2 }))); + expect(mockSetCurrentStep).not.toHaveBeenCalled(); + }); + + it('submits from the note step with the summary and the required-note check', async () => { + const noteRequiredStatus = { ...respondingStatus, Note: 2 }; + setSheetStore({ currentStep: 'add-note', selectedStatus: noteRequiredStatus, availableCalls: [dispatchedCall], selectedCall: dispatchedCall, selectedDestinationType: 'call', note: '' }); + + const { rerender } = render(); + + expect(screen.getByTestId('status-summary').props.children).toBe('Responding · C555 - Structure Fire'); + expect(findButton('Submit')?.props.accessibilityState?.disabled).toBe(true); + + setSheetStore({ currentStep: 'add-note', selectedStatus: noteRequiredStatus, availableCalls: [dispatchedCall], selectedCall: dispatchedCall, selectedDestinationType: 'call', note: 'Crew of 4' }); + rerender(); + + fireEvent.press(screen.getByText('Submit')); + + await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalledWith(expect.objectContaining({ Note: 'Crew of 4', RespondingTo: '555', RespondingToType: 2 }))); + }); + + it('keeps Next (not Submit) on the destination step when a note step follows', () => { + setSheetStore({ selectedStatus: { ...respondingStatus, Note: 1 }, availableCalls: [dispatchedCall] }); + + render(); + + expect(screen.queryByTestId('status-summary')).toBeNull(); + fireEvent.press(screen.getByText('Next')); + expect(mockSetCurrentStep).toHaveBeenCalledWith('add-note'); + }); + + it('disables Next while the destination list is loading', () => { + setCoreStore({ activeCallId: '555' }); + setSheetStore({ selectedStatus: { ...respondingStatus, Note: 1 }, isLoading: true, availableCalls: [] }); + + render(); + + expect(screen.getByText('Loading calls...')).toBeTruthy(); + expect(findButton('Next')?.props.accessibilityState?.disabled).toBe(true); + + fireEvent.press(screen.getByText('Next')); + expect(mockSetCurrentStep).not.toHaveBeenCalled(); + }); + + it('disables Submit before the first destination fetch has landed, then enables it', () => { + setSheetStore({ selectedStatus: respondingStatus, isLoading: false, lastFetchedAt: 0, error: null }); + + const { rerender } = render(); + + expect(findButton('Submit')?.props.accessibilityState?.disabled).toBe(true); + + setSheetStore({ selectedStatus: respondingStatus, isLoading: false, lastFetchedAt: Date.now(), error: null }); + rerender(); + + expect(findButton('Submit')?.props.accessibilityState?.disabled).toBe(false); + }); + + it('does not hold the destination step when the destination fetch failed', () => { + setSheetStore({ selectedStatus: respondingStatus, isLoading: false, lastFetchedAt: 0, error: 'Failed to fetch destination data' }); + + render(); + + expect(findButton('Submit')?.props.accessibilityState?.disabled).toBe(false); + }); + + it('keeps an explicit "No destination" choice even though an active call is open', async () => { + setCoreStore({ activeCallId: '555' }); + // As left by the auto-select once the list loaded + const state = mountStatefulSheetStore({ selectedStatus: respondingStatus, availableCalls: [dispatchedCall], selectedCall: dispatchedCall, selectedDestinationType: 'call' }); + + const { rerender } = render(); + + fireEvent.press(screen.getByText('No Destination')); + rerender(); + + // The auto-select must not bring the call back after the crew cleared it + expect(state.setSelectedCall).not.toHaveBeenCalledWith(dispatchedCall); + expect(findRowClassName('No Destination')).toContain('border-blue-500'); + expect(screen.getByTestId('status-summary').props.children).toBe('Responding · No Destination'); + + fireEvent.press(screen.getByText('Submit')); + + await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalledWith(expect.objectContaining({ RespondingTo: '0', RespondingToType: null }))); + expect(mockSetActiveCall).not.toHaveBeenCalled(); + }); + + it("defaults to the open call on the unit's latest status when no active call is set", async () => { + setCoreStore({ activeCallId: null, activeUnitStatus: dispatchedUnitStatus }); + setSheetStore({ selectedStatus: respondingStatus, availableCalls: [otherCall, dispatchedCall] }); + + render(); + + await waitFor(() => { + expect(mockSetSelectedCall).toHaveBeenCalledWith(dispatchedCall); + expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('call'); + }); + }); + + it('prefers the active call over the latest status destination', async () => { + setCoreStore({ activeCallId: '777', activeUnitStatus: dispatchedUnitStatus }); + setSheetStore({ selectedStatus: respondingStatus, availableCalls: [otherCall, dispatchedCall] }); + + render(); + + await waitFor(() => expect(mockSetSelectedCall).toHaveBeenCalledWith(otherCall)); + expect(mockSetSelectedCall).not.toHaveBeenCalledWith(dispatchedCall); + }); + + it('treats an untyped (legacy) latest-status destination as a call when it matches an open call', async () => { + setCoreStore({ activeCallId: null, activeUnitStatus: { ...dispatchedUnitStatus, DestinationType: null } }); + setSheetStore({ selectedStatus: respondingStatus, availableCalls: [dispatchedCall] }); + + render(); + + await waitFor(() => expect(mockSetSelectedCall).toHaveBeenCalledWith(dispatchedCall)); + }); + + it('does not default to a call when the latest status destination is a station', () => { + setCoreStore({ activeCallId: null, activeUnitStatus: { ...dispatchedUnitStatus, DestinationType: 1 } }); + setSheetStore({ selectedStatus: respondingStatus, availableCalls: [dispatchedCall] }); + + render(); + + expect(mockSetSelectedCall).not.toHaveBeenCalled(); + }); + + it('does not default to a call for a status that does not offer calls', () => { + setCoreStore({ activeCallId: null, activeUnitStatus: dispatchedUnitStatus }); + setSheetStore({ selectedStatus: { ...respondingStatus, Text: 'At Station', Detail: 1 }, availableCalls: [dispatchedCall] }); + + render(); + + expect(mockSetSelectedCall).not.toHaveBeenCalled(); + }); + + it('sends the open default call with a Detail 0 status and shows it in the summary', async () => { + setCoreStore({ activeCallId: null, activeUnitStatus: dispatchedUnitStatus }); + setSheetStore({ currentStep: 'select-destination', selectedStatus: onSceneNoDestinationStatus, availableCalls: [dispatchedCall] }); + + render(); + + // No destination step for this status… + expect(screen.queryByText('No Destination')).toBeNull(); + // …but the save is still tied to the call the unit is working + expect(screen.getByTestId('status-summary').props.children).toBe('On Scene · C555 - Structure Fire'); + + fireEvent.press(screen.getByText('Submit')); + + await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalledWith(expect.objectContaining({ Type: '3', RespondingTo: '555', RespondingToType: 2 }))); + // Carried silently, so it must not repoint the active call + expect(mockSetActiveCall).not.toHaveBeenCalled(); + // The selection itself stays empty for a status without a destination step + expect(mockSetSelectedCall).not.toHaveBeenCalledWith(dispatchedCall); + }); + + it('sends the active call with a Detail 0 status submitted from status selection', async () => { + setCoreStore({ activeCallId: '555' }); + setSheetStore({ currentStep: 'select-status', cameFromStatusSelection: true, selectedStatus: onSceneNoDestinationStatus, availableCalls: [dispatchedCall] }); + + render(); + + fireEvent.press(screen.getByText('Submit')); + + await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalledWith(expect.objectContaining({ RespondingTo: '555', RespondingToType: 2 }))); + }); + + it('sends no destination with a Detail 0 status once the default call has closed', async () => { + setCoreStore({ activeCallId: null, activeUnitStatus: dispatchedUnitStatus }); + setSheetStore({ currentStep: 'select-destination', selectedStatus: onSceneNoDestinationStatus, availableCalls: [otherCall] }); + + render(); + + expect(screen.getByTestId('status-summary').props.children).toBe('On Scene · No Destination'); + fireEvent.press(screen.getByText('Submit')); + + await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalledWith(expect.objectContaining({ RespondingTo: '0', RespondingToType: null }))); + }); + + it('holds a Detail 0 submit while the call list loads only when there is a call it could carry', () => { + setCoreStore({ activeCallId: null, activeUnitStatus: dispatchedUnitStatus }); + setSheetStore({ currentStep: 'select-destination', selectedStatus: onSceneNoDestinationStatus, isLoading: true, availableCalls: [] }); + + const { rerender } = render(); + + expect(screen.getByTestId('status-summary').props.children).toBe('On Scene · Loading calls...'); + expect(findButton('Submit')?.props.accessibilityState?.disabled).toBe(true); + + // Nothing open that this unit is tied to: an "Available" must not wait on the network + setCoreStore({ activeCallId: null, activeUnitStatus: null }); + rerender(); + + expect(screen.getByTestId('status-summary').props.children).toBe('On Scene · No Destination'); + expect(findButton('Submit')?.props.accessibilityState?.disabled).toBe(false); + }); + }); }); diff --git a/src/components/status/__tests__/status-gps-integration-working.test.tsx b/src/components/status/__tests__/status-gps-integration-working.test.tsx index edece91e..b187881b 100644 --- a/src/components/status/__tests__/status-gps-integration-working.test.tsx +++ b/src/components/status/__tests__/status-gps-integration-working.test.tsx @@ -316,7 +316,8 @@ describe('Status GPS Integration', () => { altitudeAccuracy: '', speed: '25', heading: '90', - } + }, + expect.any(Date) ); }); @@ -340,7 +341,8 @@ describe('Status GPS Integration', () => { '', null, [], - undefined + undefined, + expect.any(Date) ); }); @@ -511,7 +513,8 @@ describe('Status GPS Integration', () => { speed: '', heading: '', altitudeAccuracy: '', - } + }, + expect.any(Date) ); }); @@ -557,7 +560,8 @@ describe('Status GPS Integration', () => { speed: '30', heading: '', altitudeAccuracy: '', - } + }, + expect.any(Date) ); }); }); diff --git a/src/components/status/__tests__/status-gps-integration.test.tsx b/src/components/status/__tests__/status-gps-integration.test.tsx index cfc05eec..554efc4b 100644 --- a/src/components/status/__tests__/status-gps-integration.test.tsx +++ b/src/components/status/__tests__/status-gps-integration.test.tsx @@ -224,7 +224,8 @@ describe('Status GPS Integration', () => { altitudeAccuracy: '', speed: '25', heading: '90', - } + }, + expect.any(Date) ); }); @@ -248,7 +249,8 @@ describe('Status GPS Integration', () => { '', null, [], - undefined + undefined, + expect.any(Date) ); }); @@ -418,7 +420,8 @@ describe('Status GPS Integration', () => { altitudeAccuracy: '', speed: '', heading: '', - } + }, + expect.any(Date) ); }); @@ -464,7 +467,8 @@ describe('Status GPS Integration', () => { altitudeAccuracy: '', speed: '30', heading: '', - } + }, + expect.any(Date) ); }); }); diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx index d5fc245d..1928f2de 100644 --- a/src/components/status/status-bottom-sheet.tsx +++ b/src/components/status/status-bottom-sheet.tsx @@ -7,9 +7,13 @@ import { InteractionManager, ScrollView, TouchableOpacity } from 'react-native'; import { useKeyboardHeight } from '@/hooks/use-keyboard-height'; import { logger } from '@/lib/logging'; import { createPoiTypeMap, getPoiSelectionLabel } from '@/lib/poi-utils'; +import { getUnitStatusCallDestinationId, resolveDefaultStatusCall } from '@/lib/status-destination'; import { invertColor } from '@/lib/utils'; +import { type CallResultData } from '@/models/v4/calls/callResultData'; import { CustomStateDetailTypes, statusDetailAllowsCalls, statusDetailAllowsPois, statusDetailAllowsStations } from '@/models/v4/customStatuses/customStateDetailTypes'; import { DestinationEntityTypes } from '@/models/v4/destinations/destinationEntityTypes'; +import { type GroupResultData } from '@/models/v4/groups/groupsResultData'; +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; import { SaveUnitStatusInput, SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; import { acquireLocationFix, getLocationFixErrorMessage } from '@/services/location-fix'; import { offlineEventManager } from '@/services/offline-event-manager.service'; @@ -30,6 +34,11 @@ import { VStack } from '../ui/vstack'; type DestinationTab = 'call' | 'station' | 'poi'; +/** What a submit would actually send as RespondingTo/RespondingToType. */ +type StatusDestination = { type: 'none' } | { type: 'call'; call: CallResultData } | { type: 'station'; station: GroupResultData } | { type: 'poi'; poi: PoiResultData }; + +const NO_DESTINATION: StatusDestination = { type: 'none' }; + const getDestinationTabs = (detail: number): DestinationTab[] => { const tabs: DestinationTab[] = []; @@ -117,6 +126,8 @@ export const StatusBottomSheet = () => { const availablePois = useStatusBottomSheetStore((state) => state.availablePois); const availablePoiTypes = useStatusBottomSheetStore((state) => state.availablePoiTypes); const isLoading = useStatusBottomSheetStore((state) => state.isLoading); + const lastFetchedAt = useStatusBottomSheetStore((state) => state.lastFetchedAt); + const destinationDataError = useStatusBottomSheetStore((state) => state.error); const setCurrentStep = useStatusBottomSheetStore((state) => state.setCurrentStep); const setSelectedCall = useStatusBottomSheetStore((state) => state.setSelectedCall); const setSelectedStation = useStatusBottomSheetStore((state) => state.setSelectedStation); @@ -128,6 +139,7 @@ export const StatusBottomSheet = () => { const reset = useStatusBottomSheetStore((state) => state.reset); const activeUnit = useCoreStore((state) => state.activeUnit); + const activeUnitStatus = useCoreStore((state) => state.activeUnitStatus); const activeCallId = useCoreStore((state) => state.activeCallId); const setActiveCall = useCoreStore((state) => state.setActiveCall); const activeStatuses = useCoreStore((state) => state.activeStatuses); @@ -166,13 +178,66 @@ export const StatusBottomSheet = () => { const noteType = getStatusProperty('Note', 0); const isNoteRequired = noteType === 2; const isNoteOptional = noteType === 1; - const activeCallCandidate = React.useMemo(() => { - if (!activeCallId) { - return null; + const hasNoteStep = noteType > 0; + const allowsCalls = statusDetailAllowsCalls(detailLevel); + const allowsStations = statusDetailAllowsStations(detailLevel); + const allowsPois = statusDetailAllowsPois(detailLevel); + + // Set once the crew taps a destination row (including "No destination") so the default call is + // applied at most once per sheet open — without it the auto-select re-picked the call the moment + // the user cleared it, and "No destination" could never be chosen while a call was open. + const [hasExplicitDestinationChoice, setHasExplicitDestinationChoice] = React.useState(false); + + const unitStatusCallId = React.useMemo(() => getUnitStatusCallDestinationId(activeUnitStatus, activeUnit?.UnitId), [activeUnitStatus, activeUnit?.UnitId]); + + // The call this status defaults to: the active call, else the open call the unit's latest status + // points at (dispatch writes a "Responding" status carrying the dispatched call). + const defaultCall = React.useMemo( + () => resolveDefaultStatusCall({ availableCalls, activeCallId, unitStatus: activeUnitStatus, unitId: activeUnit?.UnitId }), + [activeCallId, activeUnit?.UnitId, activeUnitStatus, availableCalls] + ); + const hasPotentialDefaultCall = !!activeCallId || !!unitStatusCallId; + + // Destination data is refetched on every open (reset() clears it), and the default call can only + // be resolved against it. Until it lands — including the render before the fetch effect fires — + // anything that submits would send RespondingTo '0' and silently drop the call. + const isDestinationDataPending = isOpen && !!activeUnit && (isLoading || (lastFetchedAt === 0 && !destinationDataError)); + // A status with a destination step waits for its lists; a status without one only waits when + // there is a call it could be carrying, so an "Available" with nothing open is never held up. + const isAwaitingDestinationData = isDestinationDataPending && (shouldShowDestinationStep || hasPotentialDefaultCall); + + const effectiveDestination = React.useMemo((): StatusDestination => { + if (!selectedStatus) { + return NO_DESTINATION; } - return availableCalls.find((call) => call.CallId === activeCallId) ?? null; - }, [activeCallId, availableCalls]); + if (detailLevel === 0) { + // No destination step for this status, but a department whose "On Scene" is configured with + // no destination still needs the status tied to the call it is working — carry the open + // default call silently (it is shown in the summary line). + return defaultCall ? { type: 'call', call: defaultCall } : NO_DESTINATION; + } + + if (selectedDestinationType === 'call' && selectedCall && allowsCalls) { + return { type: 'call', call: selectedCall }; + } + + if (selectedDestinationType === 'station' && selectedStation && allowsStations) { + return { type: 'station', station: selectedStation }; + } + + if (selectedDestinationType === 'poi' && selectedPoi && allowsPois) { + return { type: 'poi', poi: selectedPoi }; + } + + // The auto-select effect applies the default a render after the list lands; resolving it here + // too means a tap inside that window still sends the call rather than '0'. + if (!hasExplicitDestinationChoice && allowsCalls && defaultCall) { + return { type: 'call', call: defaultCall }; + } + + return NO_DESTINATION; + }, [allowsCalls, allowsPois, allowsStations, defaultCall, detailLevel, hasExplicitDestinationChoice, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedStatus]); // When the active call is auto-selected the destination list should scroll it // into view. The id is parked here until the selected row's onLayout reports @@ -209,6 +274,8 @@ export const StatusBottomSheet = () => { if (!isOpen) { pendingScrollToCallIdRef.current = null; callRowOffsetsRef.current = {}; + // The sheet stays mounted at the root, so the next open must start with the default again. + setHasExplicitDestinationChoice(false); } }, [isOpen]); @@ -243,10 +310,8 @@ export const StatusBottomSheet = () => { return; } - const allowsCalls = statusDetailAllowsCalls(detailLevel); - const allowsStations = statusDetailAllowsStations(detailLevel); - const allowsPois = statusDetailAllowsPois(detailLevel); - + // A Detail 0 status still carries the default call, but through effectiveDestination rather + // than the selection — the selection only ever holds what the destination step offers. if (!allowsCalls && selectedCall) { setSelectedCall(null); } @@ -265,7 +330,7 @@ export const StatusBottomSheet = () => { if (!selectedTypeAllowed) { setSelectedDestinationType('none'); } - }, [detailLevel, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedStatus, setSelectedCall, setSelectedDestinationType, setSelectedPoi, setSelectedStation]); + }, [allowsCalls, allowsPois, allowsStations, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedStatus, setSelectedCall, setSelectedDestinationType, setSelectedPoi, setSelectedStation]); React.useEffect(() => { if (!selectedStatus || selectedDestinationType !== 'none') { @@ -288,7 +353,9 @@ export const StatusBottomSheet = () => { }, [detailLevel, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedStatus, setSelectedDestinationType]); React.useEffect(() => { - if (!isOpen || !selectedStatus || !statusDetailAllowsCalls(detailLevel) || !activeCallCandidate) { + // Once the crew has picked a destination row — "No destination" included — the default must not + // come back: re-applying it here is what made an explicit "No destination" impossible. + if (!isOpen || !selectedStatus || !allowsCalls || !defaultCall || hasExplicitDestinationChoice) { return; } @@ -296,15 +363,28 @@ export const StatusBottomSheet = () => { return; } - setSelectedCall(activeCallCandidate); + setSelectedCall(defaultCall); setSelectedDestinationType('call'); - pendingScrollToCallIdRef.current = activeCallCandidate.CallId; + pendingScrollToCallIdRef.current = defaultCall.CallId; // If the rows are already laid out no further onLayout will fire, so drive // the scroll ourselves once the current interactions settle. const interaction = InteractionManager.runAfterInteractions(flushPendingCallScroll); return () => interaction.cancel(); - }, [activeCallCandidate, detailLevel, flushPendingCallScroll, isOpen, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedStatus, setSelectedCall, setSelectedDestinationType]); + }, [ + allowsCalls, + defaultCall, + flushPendingCallScroll, + hasExplicitDestinationChoice, + isOpen, + selectedCall, + selectedDestinationType, + selectedPoi, + selectedStation, + selectedStatus, + setSelectedCall, + setSelectedDestinationType, + ]); // Auto-pick the initial tab when the sheet opens or the status changes. // After that the user's manual tab taps must win — recomputing the preferred @@ -360,6 +440,7 @@ export const StatusBottomSheet = () => { return; } + setHasExplicitDestinationChoice(true); setSelectedCall(call); setSelectedStation(null); setSelectedPoi(null); @@ -372,6 +453,7 @@ export const StatusBottomSheet = () => { return; } + setHasExplicitDestinationChoice(true); setSelectedStation(station); setSelectedCall(null); setSelectedPoi(null); @@ -384,6 +466,7 @@ export const StatusBottomSheet = () => { return; } + setHasExplicitDestinationChoice(true); setSelectedPoi(poi); setSelectedCall(null); setSelectedStation(null); @@ -391,6 +474,7 @@ export const StatusBottomSheet = () => { }; const handleNoDestinationSelect = () => { + setHasExplicitDestinationChoice(true); setSelectedDestinationType('none'); setSelectedCall(null); setSelectedStation(null); @@ -402,26 +486,28 @@ export const StatusBottomSheet = () => { return; } + // There is no separate review step: whichever step is last submits (see isLastStep), with the + // summary line showing what will be saved. if (currentStep === 'select-status') { - if (detailLevel > 0) { + if (shouldShowDestinationStep) { setCurrentStep('select-destination'); return; } - if (noteType === 0) { - void handleSubmit(); - } else { + if (hasNoteStep) { setCurrentStep('add-note'); + } else { + void handleSubmit(); } return; } if (currentStep === 'select-destination') { - if (noteType === 0) { - void handleSubmit(); - } else { + if (hasNoteStep) { setCurrentStep('add-note'); + } else { + void handleSubmit(); } } }; @@ -451,7 +537,8 @@ export const StatusBottomSheet = () => { }; const handleSubmit = React.useCallback(async () => { - if (isSubmitting || !selectedStatus || !activeUnit) { + // Submitting before the destination data lands would send RespondingTo '0' and drop the call. + if (isSubmitting || !selectedStatus || !activeUnit || isAwaitingDestinationData) { return; } @@ -465,14 +552,17 @@ export const StatusBottomSheet = () => { input.RespondingTo = '0'; input.RespondingToType = null; - if (selectedDestinationType === 'call' && selectedCall) { - input.RespondingTo = selectedCall.CallId; + // RespondingTo + RespondingToType are the only link between a unit status and a call on the + // server's call reports, so build them from the same resolution the summary line shows. + const destination = effectiveDestination; + if (destination.type === 'call') { + input.RespondingTo = destination.call.CallId; input.RespondingToType = DestinationEntityTypes.Call; - } else if (selectedDestinationType === 'station' && selectedStation) { - input.RespondingTo = selectedStation.GroupId; + } else if (destination.type === 'station') { + input.RespondingTo = destination.station.GroupId; input.RespondingToType = DestinationEntityTypes.Station; - } else if (selectedDestinationType === 'poi' && selectedPoi) { - input.RespondingTo = selectedPoi.PoiId.toString(); + } else if (destination.type === 'poi') { + input.RespondingTo = destination.poi.PoiId.toString(); input.RespondingToType = DestinationEntityTypes.Poi; } @@ -525,8 +615,10 @@ export const StatusBottomSheet = () => { return roleInput; }); - if (selectedDestinationType === 'call' && selectedCall && activeCallId !== selectedCall.CallId) { - setActiveCall(selectedCall.CallId); + // A call picked on the destination step becomes the active call. The call a Detail 0 status + // carries silently is not something the crew chose, so it must not repoint the active call. + if (destination.type === 'call' && shouldShowDestinationStep && activeCallId !== destination.call.CallId) { + setActiveCall(destination.call.CallId); } await saveUnitStatus(input); @@ -544,35 +636,36 @@ export const StatusBottomSheet = () => { }, [ activeCallId, activeUnit, + effectiveDestination, getStatusId, + isAwaitingDestinationData, isSubmitting, note, reset, saveUnitStatus, - selectedCall, - selectedDestinationType, - selectedPoi, - selectedStation, selectedStatus, setActiveCall, + shouldShowDestinationStep, showToast, t, unitRoleAssignments, ]); + // True while the default call is still to be applied (list loading, or loaded but the auto-select + // effect has not run yet) — "No destination" must not flash as the choice in that window. + const isDefaultCallPending = isOpen && !!selectedStatus && allowsCalls && !hasExplicitDestinationChoice && hasPotentialDefaultCall && (isDestinationDataPending || !!defaultCall); + const shouldShowNoDestinationAsSelected = React.useMemo(() => { if (selectedCall || selectedStation || selectedPoi) { return false; } - const shouldPreSelectActiveCall = isOpen && !!selectedStatus && statusDetailAllowsCalls(detailLevel) && !!activeCallId && (isLoading || !!activeCallCandidate); - - if (shouldPreSelectActiveCall) { + if (isDefaultCallPending) { return false; } return selectedDestinationType === 'none'; - }, [activeCallCandidate, activeCallId, detailLevel, isLoading, isOpen, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedStatus]); + }, [isDefaultCallPending, selectedCall, selectedDestinationType, selectedPoi, selectedStation]); const getStepTitle = () => { switch (currentStep) { @@ -610,11 +703,7 @@ export const StatusBottomSheet = () => { let totalSteps = 1; if (selectedStatus) { - const hasDestinationSelection = getStatusProperty('Detail', 0) > 0; - const currentNoteType = getStatusProperty('Note', 0); - const hasNoteStep = currentNoteType > 0; - - if (hasDestinationSelection) { + if (shouldShowDestinationStep) { totalSteps += 1; } @@ -652,6 +741,24 @@ export const StatusBottomSheet = () => { return Math.max(totalSteps, 1); }; + // The step that submits. There is no separate review step: the note step is last when the status + // takes a note, otherwise the destination step, otherwise status selection itself. + const isLastStep = (() => { + switch (currentStep) { + case 'select-status': + return !!selectedStatus && !shouldShowDestinationStep && !hasNoteStep; + case 'select-destination': + // Without destination options this renders as the single submit view for a status picked up front. + return !shouldShowDestinationStep || !hasNoteStep; + case 'add-note': + return true; + default: + return false; + } + })(); + + const isNoteSatisfied = !isNoteRequired || note.trim().length > 0; + const canProceedFromCurrentStep = () => { if (isSubmitting) { return false; @@ -659,42 +766,94 @@ export const StatusBottomSheet = () => { switch (currentStep) { case 'select-status': - return !!selectedStatus; + if (!selectedStatus) { + return false; + } + + // Moving on to the destination step is fine while it loads (it shows the spinner); a status + // that submits from here has to wait for the default call like any other submit. + return !(isLastStep && isAwaitingDestinationData); case 'select-destination': - return true; + if (isAwaitingDestinationData) { + return false; + } + + return shouldShowDestinationStep || isNoteSatisfied; case 'add-note': - return !isNoteRequired || note.trim().length > 0; + return isNoteSatisfied && !isAwaitingDestinationData; default: return false; } }; const getSelectedDestinationDisplay = () => { - if (selectedCall) { - return `${selectedCall.Number} - ${selectedCall.Name}`; - } - - if (selectedStation) { - return selectedStation.Name; + switch (effectiveDestination.type) { + case 'call': + return `${effectiveDestination.call.Number} - ${effectiveDestination.call.Name}`; + case 'station': + return effectiveDestination.station.Name; + case 'poi': + return getPoiSelectionLabel(effectiveDestination.poi, poiTypesById); + default: + break; } - if (selectedPoi) { - return getPoiSelectionLabel(selectedPoi, poiTypesById); + // Still resolving whether the status carries the default call. + const mayCarryDefaultCall = detailLevel === 0 || (allowsCalls && !hasExplicitDestinationChoice); + if (isDestinationDataPending && hasPotentialDefaultCall && mayCarryDefaultCall) { + return t('calls.loading_calls'); } - if (selectedDestinationType === 'call') { - if (activeCallCandidate) { - return `${activeCallCandidate.Number} - ${activeCallCandidate.Name}`; - } + return t('status.no_destination'); + }; - if (isLoading || (!!activeCallId && availableCalls.length === 0)) { - return t('calls.loading_calls'); - } + // Compact one-line "status · destination" on the last step, so the crew sees exactly what will be + // saved without a separate review step. A single Text keeps it on one line and stops it from + // duplicating the "No destination" row text on the destination step. + const renderStatusSummary = () => { + if (!selectedStatus) { + return null; } - return t('status.no_destination'); + const summaryBackground = selectedStatus.BColor || '#f3f4f6'; + const destinationText = getSelectedDestinationDisplay(); + + return ( + + + {`${selectedStatus.Text} · ${destinationText}`} + + + ); }; + const renderSubmitButtonContent = () => ( + <> + {isSubmitting ? : null} + {isSubmitting ? t('common.submitting') : t('common.submit')} + + ); + + // Status and destination steps advance with Next, or submit when they are the last step. + const renderAdvanceButton = () => ( + + ); + // Opening straight into 'add-note' (status picked up front, no destination to choose) // leaves nothing to go back to, so the note step offers Cancel instead of Previous. const hasStepBeforeNote = cameFromStatusSelection || shouldShowDestinationStep; @@ -769,14 +928,13 @@ export const StatusBottomSheet = () => { + {isLastStep ? renderStatusSummary() : null} + - + {renderAdvanceButton()} ) : null} @@ -811,7 +969,7 @@ export const StatusBottomSheet = () => { {showCalls ? ( - {isLoading ? ( + {isDestinationDataPending ? ( {t('calls.loading_calls')} @@ -852,7 +1010,7 @@ export const StatusBottomSheet = () => { {showStations ? ( - {isLoading ? ( + {isDestinationDataPending ? ( {t('status.loading_stations')} @@ -882,7 +1040,7 @@ export const StatusBottomSheet = () => { {showPois ? ( - {isLoading ? ( + {isDestinationDataPending ? ( {t('status.loading_pois')} @@ -915,21 +1073,20 @@ export const StatusBottomSheet = () => { ) : null} + {isLastStep ? renderStatusSummary() : null} + {cameFromStatusSelection ? ( - ) : ( - )} - + {renderAdvanceButton()} ) : null} @@ -947,6 +1104,7 @@ export const StatusBottomSheet = () => { keyboardShouldPersistTaps="handled" > + {renderStatusSummary()} {isNoteRequired || isNoteOptional ? ( <> {t('status.add_note')} @@ -957,18 +1115,17 @@ export const StatusBottomSheet = () => { ) : null} {cameFromStatusSelection ? ( - ) : ( - )} - @@ -988,19 +1145,7 @@ export const StatusBottomSheet = () => { keyboardShouldPersistTaps="handled" > - - {t('status.selected_status')}: - - - {selectedStatus?.Text} - - - - - - {t('status.selected_destination')}: - {getSelectedDestinationDisplay()} - + {renderStatusSummary()} @@ -1022,9 +1167,8 @@ export const StatusBottomSheet = () => { {t('common.cancel')} )} - diff --git a/src/hooks/__tests__/use-checklist-live-updates.test.tsx b/src/hooks/__tests__/use-checklist-live-updates.test.tsx new file mode 100644 index 00000000..94556ed7 --- /dev/null +++ b/src/hooks/__tests__/use-checklist-live-updates.test.tsx @@ -0,0 +1,35 @@ +jest.mock('expo-router', () => ({ useFocusEffect: (callback: () => void) => require('react').useEffect(callback, [callback]) })); +jest.mock('@/lib/env', () => ({ Env: { CHANNEL_HUB_NAME: 'updates' } })); +jest.mock('@/services/signalr.service', () => ({ SignalRService: { HUB_RECONNECTED_EVENT: 'reconnected' }, HUB_CONNECTED_EVENT: 'connected', signalRService: { on: jest.fn(), off: jest.fn() } })); +jest.mock('@/stores/auth/store', () => { const { create } = jest.requireActual('zustand'); return { __esModule: true, default: create(() => ({ userId: 'author' })) }; }); +jest.mock('@/stores/security/store', () => { const { create } = jest.requireActual('zustand'); return { securityStore: create(() => ({ rights: { DepartmentId: 77 } })) }; }); +jest.mock('@/stores/checklists/store', () => { const { create } = jest.requireActual('zustand'); return { checklistScope: () => 'scope', useChecklistsStore: create(() => ({ locked: false, busy: false, active: null, access: { IsProtected: false } })) }; }); +jest.mock('@/stores/data-protection/store', () => ({ dataProtectionStore: { getState: () => ({ isStepUpActive: () => false }) } })); +import { act, renderHook } from '@testing-library/react-native'; +import { AppState } from 'react-native'; +import { signalRService } from '@/services/signalr.service'; +import { useChecklistsStore } from '@/stores/checklists/store'; +import { useChecklistLiveUpdates } from '../use-checklist-live-updates'; +const event = (name: string) => jest.mocked(signalRService.on).mock.calls.find(([key]) => key === name)![1]; +beforeEach(() => { jest.useFakeTimers(); jest.clearAllMocks(); jest.spyOn(AppState, 'addEventListener').mockReturnValue({ remove: jest.fn() }); Object.defineProperty(AppState, 'currentState', { configurable: true, value: 'active' }); useChecklistsStore.setState({ locked: false, busy: false, active: null, access: { IsProtected: false } as any }); }); +afterEach(() => { jest.useRealTimers(); }); +it('coalesces value-free hints and cleans listeners on blur/unmount', () => { + const refresh = jest.fn(); const hook = renderHook(() => useChecklistLiveUpdates(refresh)); + act(() => { event('checklistUpdated')('untrusted payload'); event('checklistUpdated')('second'); jest.advanceTimersByTime(500); }); + expect(refresh).toHaveBeenCalledTimes(1); expect(refresh).toHaveBeenCalledWith(); + hook.unmount(); expect(signalRService.off).toHaveBeenCalledWith('checklistUpdated', expect.any(Function)); +}); +it('defers while editing and refetches once the draft closes', () => { + const refresh = jest.fn(); const hook = renderHook(() => useChecklistLiveUpdates(refresh)); + act(() => { useChecklistsStore.setState({ active: { id: 'draft' } as any }); event('checklistUpdated')(); jest.advanceTimersByTime(500); }); + expect(refresh).not.toHaveBeenCalled(); + act(() => { useChecklistsStore.setState({ active: null }); jest.advanceTimersByTime(500); }); + expect(refresh).toHaveBeenCalledTimes(1); hook.unmount(); +}); +it('does not fetch in background, while locked, or with an expired protected grant', () => { + const refresh = jest.fn(); const hook = renderHook(() => useChecklistLiveUpdates(refresh)); + act(() => { useChecklistsStore.setState({ locked: true, access: { IsProtected: true } as any }); event('checklistUpdated')(); jest.advanceTimersByTime(500); }); + act(() => { useChecklistsStore.setState({ locked: false, access: { IsProtected: true } as any }); event('checklistUpdated')(); jest.advanceTimersByTime(500); }); + act(() => { Object.defineProperty(AppState, 'currentState', { configurable: true, value: 'background' }); useChecklistsStore.setState({ access: { IsProtected: false } as any }); event('checklistUpdated')(); jest.advanceTimersByTime(500); }); + expect(refresh).not.toHaveBeenCalled(); hook.unmount(); +}); diff --git a/src/hooks/use-checklist-live-updates.ts b/src/hooks/use-checklist-live-updates.ts new file mode 100644 index 00000000..672604c4 --- /dev/null +++ b/src/hooks/use-checklist-live-updates.ts @@ -0,0 +1,56 @@ +import { useFocusEffect } from 'expo-router'; +import { useCallback, useRef } from 'react'; +import { AppState } from 'react-native'; + +import { Env } from '@/lib/env'; +import { SignalRService, signalRService } from '@/services/signalr.service'; +import useAuthStore from '@/stores/auth/store'; +import { checklistScope, useChecklistsStore } from '@/stores/checklists/store'; +import { dataProtectionStore } from '@/stores/data-protection/store'; +import { securityStore } from '@/stores/security/store'; + +// SignalR carries only an invalidation hint. Each consumer refetches through its normal +// scoped API/ADP boundary. No event payload is retained, logged, or treated as report data. +export const useChecklistLiveUpdates = (refresh: () => void) => { + const callback = useRef(refresh); + callback.current = refresh; + const user = useAuthStore((s) => s.userId); + const department = securityStore((s) => s.rights?.DepartmentId); + useFocusEffect( + useCallback(() => { + const scope = user && department ? checklistScope() : null; + let timer: ReturnType | null = null; + let pending = false; + const flush = () => { + timer = null; + const state = useChecklistsStore.getState(); + if (!pending || !scope || scope !== checklistScope() || AppState.currentState !== 'active' || state.busy || state.active || (state.access?.IsProtected && !dataProtectionStore.getState().isStepUpActive())) return; + pending = false; + callback.current(); + }; + const changed = () => { + pending = true; + if (timer) clearTimeout(timer); + timer = setTimeout(flush, 500); + }; + const connected = (hub: unknown) => { + if (hub === Env.CHANNEL_HUB_NAME) changed(); + }; + signalRService.on('checklistUpdated', changed); + signalRService.on(SignalRService.HUB_RECONNECTED_EVENT, connected); + const app = AppState.addEventListener('change', (value) => { + if (value === 'active') changed(); + }); + const state = useChecklistsStore.subscribe(() => { + if (pending && !timer) changed(); + }); + return () => { + signalRService.off('checklistUpdated', changed); + signalRService.off(SignalRService.HUB_RECONNECTED_EVENT, connected); + app.remove(); + state(); + if (timer) clearTimeout(timer); + }; + }, [user, department]) + ); +}; diff --git a/src/hooks/use-records-context.ts b/src/hooks/use-records-context.ts new file mode 100644 index 00000000..43e1be49 --- /dev/null +++ b/src/hooks/use-records-context.ts @@ -0,0 +1,28 @@ +import { useMemo } from 'react'; + +import { type FieldRecordContextInput } from '@/models/v4/records'; +import { useCoreStore } from '@/stores/app/core-store'; + +/** + * The field context this app authors in (RMS plan RMS-1D). Unit authors against the apparatus this + * device is signed on to, plus the Call it is working when there is one. The server checks that the + * member is actually staffed on that unit before it offers a Unit-surface definition, so a stale id + * here narrows the catalog rather than widening it. + */ +export const useRecordsContext = (): FieldRecordContextInput => { + const activeUnitId = useCoreStore((state) => state.activeUnitId); + const activeCallId = useCoreStore((state) => state.activeCallId); + + return useMemo(() => { + const context: FieldRecordContextInput = {}; + const unitId = activeUnitId ? Number.parseInt(activeUnitId, 10) : Number.NaN; + if (Number.isFinite(unitId) && unitId > 0) { + context.UnitId = unitId; + } + const callId = activeCallId ? Number.parseInt(activeCallId, 10) : Number.NaN; + if (Number.isFinite(callId) && callId > 0) { + context.CallId = callId; + } + return context; + }, [activeUnitId, activeCallId]); +}; diff --git a/src/lib/__tests__/activity-link-kind.test.ts b/src/lib/__tests__/activity-link-kind.test.ts new file mode 100644 index 00000000..a0e902d2 --- /dev/null +++ b/src/lib/__tests__/activity-link-kind.test.ts @@ -0,0 +1,34 @@ +import { getActivityLinkKind } from '@/lib/activity-link-kind'; +import { StatusDestinationSources } from '@/models/v4/calls/statusDestinationSources'; + +describe('getActivityLinkKind', () => { + it.each([ + [StatusDestinationSources.CarryForward, 'CarryForward'], + [StatusDestinationSources.Dispatch, 'Dispatch'], + [StatusDestinationSources.Unit, 'Unit'], + ])('marks source %p (%s) as auto-linked', (source) => { + expect(getActivityLinkKind(source)).toBe('auto'); + }); + + it('marks the Inferred source as inferred', () => { + expect(getActivityLinkKind(StatusDestinationSources.Inferred)).toBe('inferred'); + }); + + it('shows nothing for an explicit link', () => { + expect(getActivityLinkKind(StatusDestinationSources.Explicit)).toBeNull(); + }); + + it.each([[null], [undefined], ['']])('shows nothing without a source (%p)', (source) => { + expect(getActivityLinkKind(source)).toBeNull(); + }); + + it.each([[0], [6], [-1], [Number.NaN], ['abc']])('shows nothing for an unknown source (%p)', (source) => { + expect(getActivityLinkKind(source)).toBeNull(); + }); + + it('accepts string-typed values from the API', () => { + expect(getActivityLinkKind('3')).toBe('auto'); + expect(getActivityLinkKind('5')).toBe('inferred'); + expect(getActivityLinkKind('1')).toBeNull(); + }); +}); diff --git a/src/lib/__tests__/status-destination.test.ts b/src/lib/__tests__/status-destination.test.ts new file mode 100644 index 00000000..acce0103 --- /dev/null +++ b/src/lib/__tests__/status-destination.test.ts @@ -0,0 +1,58 @@ +import { getUnitStatusCallDestinationId, resolveDefaultStatusCall } from '@/lib/status-destination'; +import { type CallResultData } from '@/models/v4/calls/callResultData'; + +const call = (CallId: string): CallResultData => ({ CallId, Number: `C${CallId}`, Name: `Call ${CallId}` }) as CallResultData; + +describe('getUnitStatusCallDestinationId', () => { + it('returns the id of a call destination', () => { + expect(getUnitStatusCallDestinationId({ UnitId: 'unit-1', DestinationId: 555, DestinationType: 2 }, 'unit-1')).toBe('555'); + }); + + it('accepts string-typed values from the API', () => { + expect(getUnitStatusCallDestinationId({ UnitId: '1', DestinationId: '555', DestinationType: '2' }, '1')).toBe('555'); + }); + + it.each([[null], [undefined], [''], [0]])('treats a legacy untyped destination (%p) as a call', (DestinationType) => { + expect(getUnitStatusCallDestinationId({ UnitId: 'unit-1', DestinationId: 555, DestinationType }, 'unit-1')).toBe('555'); + }); + + it.each([[1], [3], ['1']])('ignores station and POI destinations (type %p)', (DestinationType) => { + expect(getUnitStatusCallDestinationId({ UnitId: 'unit-1', DestinationId: 555, DestinationType }, 'unit-1')).toBeNull(); + }); + + it.each([[null], [undefined], [0], ['0'], ['']])('returns null when the status has no destination (%p)', (DestinationId) => { + expect(getUnitStatusCallDestinationId({ UnitId: 'unit-1', DestinationId, DestinationType: 2 }, 'unit-1')).toBeNull(); + }); + + it('never borrows the destination of a different unit', () => { + expect(getUnitStatusCallDestinationId({ UnitId: 'unit-2', DestinationId: 555, DestinationType: 2 }, 'unit-1')).toBeNull(); + }); + + it('returns null without a unit status', () => { + expect(getUnitStatusCallDestinationId(null, 'unit-1')).toBeNull(); + }); +}); + +describe('resolveDefaultStatusCall', () => { + const unitStatus = { UnitId: 'unit-1', DestinationId: 555, DestinationType: 2 }; + + it('prefers the active call when it is open', () => { + expect(resolveDefaultStatusCall({ availableCalls: [call('555'), call('777')], activeCallId: '777', unitStatus, unitId: 'unit-1' })?.CallId).toBe('777'); + }); + + it("falls back to the unit's latest status call when the active call is closed", () => { + expect(resolveDefaultStatusCall({ availableCalls: [call('555')], activeCallId: '777', unitStatus, unitId: 'unit-1' })?.CallId).toBe('555'); + }); + + it("uses the unit's latest status call when no active call is set", () => { + expect(resolveDefaultStatusCall({ availableCalls: [call('555')], activeCallId: null, unitStatus, unitId: 'unit-1' })?.CallId).toBe('555'); + }); + + it('returns null when the call is no longer open', () => { + expect(resolveDefaultStatusCall({ availableCalls: [call('777')], activeCallId: null, unitStatus, unitId: 'unit-1' })).toBeNull(); + }); + + it('returns null when there is nothing to default to', () => { + expect(resolveDefaultStatusCall({ availableCalls: [call('555')], activeCallId: null, unitStatus: null, unitId: 'unit-1' })).toBeNull(); + }); +}); diff --git a/src/lib/activity-link-kind.ts b/src/lib/activity-link-kind.ts new file mode 100644 index 00000000..fec65b3e --- /dev/null +++ b/src/lib/activity-link-kind.ts @@ -0,0 +1,30 @@ +import { StatusDestinationSources } from '@/models/v4/calls/statusDestinationSources'; + +/** The marker a call activity entry carries: `auto` (auto-linked) or `inferred`. */ +export type ActivityLinkKind = 'auto' | 'inferred'; + +/** + * Which marker a call activity entry gets from its `DestinationSource`. + * + * - CarryForward / Dispatch / Unit → `auto`: sent without a call, and the server linked it from the + * previous status, the one open dispatch, or the unit the person rode. + * - Inferred → `inferred`: a unit/person dispatched to the call set it with no destination while + * working it. + * - Explicit, null/undefined (older rows, non-status entries) and unknown values → null (no marker). + */ +export const getActivityLinkKind = (source: number | string | null | undefined): ActivityLinkKind | null => { + if (source === null || source === undefined || source === '') { + return null; + } + + switch (Number(source)) { + case StatusDestinationSources.CarryForward: + case StatusDestinationSources.Dispatch: + case StatusDestinationSources.Unit: + return 'auto'; + case StatusDestinationSources.Inferred: + return 'inferred'; + default: + return null; + } +}; diff --git a/src/lib/checklists/__tests__/fixtures.ts b/src/lib/checklists/__tests__/fixtures.ts new file mode 100644 index 00000000..7e420c28 --- /dev/null +++ b/src/lib/checklists/__tests__/fixtures.ts @@ -0,0 +1,7 @@ +import type { ChecklistDraft, ChecklistRun } from '@/models/v4/checklists'; +export const run = (): ChecklistRun => ({ Id: '11111111-1111-4111-8111-111111111111', DefinitionId: 'definition', VersionId: 'version-1', VersionNumber: 1, OccurrenceId: null, Revision: 1, State: 0, + IsProtected: false, IsPreview: false, CanEdit: true, CreatedBy: 'author', UpdatedOn: '2026-09-08T10:00:00Z', + Target: { Type: 0, Id: '77', Name: 'Synthetic department' }, Input: { Revision: 1, Answers: [{ ItemId: 'first', Status: 1, Value: 'pass' }] }, Files: [], + Form: { Name: 'Synthetic readiness check', Category: 0, TargetType: 0, PassThreshold: 100, RequireLocation: false, RequiresIndependentWitness: false, + Sections: [{ Id: 'section', Name: 'Checks', Items: [{ Id: 'first', Name: 'Ready', Type: 0, Required: true, Critical: true, AllowNotApplicable: true, RequireNoteOnFail: true, RequirePhotoOnFail: true, Weight: 1, PassingValue: 'pass', Options: [] }] }] } }); +export const draft = (): ChecklistDraft => { const data = run(); return { id: data.Id, scope: 'server|77|author', start: { CompletionId: data.Id, DefinitionId: data.DefinitionId, VersionId: data.VersionId, TargetId: data.Target.Id }, run: data, input: { ...data.Input }, images: [], queued: true, submit: true }; }; diff --git a/src/lib/checklists/__tests__/sync.test.ts b/src/lib/checklists/__tests__/sync.test.ts new file mode 100644 index 00000000..1710e364 --- /dev/null +++ b/src/lib/checklists/__tests__/sync.test.ts @@ -0,0 +1,59 @@ +import type { ChecklistDraft, ChecklistRun } from '@/models/v4/checklists'; +import { checklistFailed, isChecklistRequired, visibleChecklistItems } from '@/lib/checklists/form'; +import { checklistError, syncChecklistDraft, type ChecklistSyncPort } from '@/lib/checklists/sync'; +import { draft, run } from '@/lib/checklists/__tests__/fixtures'; + +const port = (): jest.Mocked => ({ + start: jest.fn, [ChecklistDraft]>(async () => run()), + upload: jest.fn, [ChecklistDraft, number]>(async () => ({ ...run(), Revision: 2 })), + save: jest.fn, [ChecklistDraft]>(async d => ({ ...run(), Revision: 3, Input: { ...d.input, Revision: 3 }, State: 2, CanEdit: false })), + persist: jest.fn, [ChecklistDraft]>(async () => undefined), check: jest.fn(), +}); + +describe('checklist offline sync', () => { + it('uses the pinned form and uploads evidence before finalization, persisting each receipt', async () => { + const local = draft(); local.images = [{ id: 'photo', itemId: 'first', name: 'photo.png', contentType: 'image/png', base64: 'synthetic' }]; const p = port(); + p.save.mockImplementation(async d => { expect(d.input.Revision).toBe(2); return { ...run(), State: 2, Revision: 3, Input: { ...d.input, Revision: 3 } }; }); + const result = await syncChecklistDraft(local, p); + expect(p.upload).toHaveBeenCalledTimes(1); + expect(p.persist).toHaveBeenCalledTimes(2); expect(result.queued).toBe(false); expect(result.images).toEqual([]); + expect(local.images).toHaveLength(1); expect(local.input.Revision).toBe(1); + }); + it('does not silently adopt a newer server revision for different local answers', async () => { + const p = port(); p.start.mockResolvedValue({ ...run(), Revision: 8 }); + p.save.mockRejectedValue({ response: { status: 409 } }); + await expect(syncChecklistDraft(draft(), p)).rejects.toEqual({ response: { status: 409 } }); + expect(p.save.mock.calls[0][0].input.Revision).toBe(1); expect(p.persist).not.toHaveBeenCalled(); + }); + it('retries a final-response loss using the same answers and no new evidence uploads', async () => { + const p = port(); p.start.mockResolvedValue({ ...run(), State: 2, Revision: 3, CanEdit: false }); + const result = await syncChecklistDraft(draft(), p); + expect(p.upload).not.toHaveBeenCalled(); expect(p.save.mock.calls[0][0].input.Answers).toEqual(draft().input.Answers); expect(result.run.State).toBe(2); + }); + it('refuses a mismatched version and preserves the original encrypted draft input', async () => { + const local = draft(); const p = port(); p.start.mockResolvedValue({ ...run(), VersionId: 'different' }); + await expect(syncChecklistDraft(local,p)).rejects.toThrow('checklist_conflict'); expect(p.save).not.toHaveBeenCalled(); expect(local.queued).toBe(true); + }); + it('rechecks permission after each awaited operation', async () => { + const p=port(); p.check.mockImplementationOnce(() => undefined).mockImplementation(() => { throw new Error('locked'); }); + await expect(syncChecklistDraft(draft(),p)).rejects.toThrow('locked'); expect(p.upload).not.toHaveBeenCalled(); expect(p.save).not.toHaveBeenCalled(); + }); + it('maps server failures to safe codes without propagating response values', () => { + expect(checklistError({ response: { status:403, data:{type:'protected_data_required',title:'PRIVATE'} } })).toBe('locked'); + expect(checklistError({ response: { status:409, data:{title:'PRIVATE'} } })).toBe('conflict'); + expect(checklistError(new Error('PRIVATE'))).toBe('retry'); + }); +}); +describe('checklist conditional form', () => { + it('hides dependent branches after the earlier answer changes', () => { + const form = run().Form; form.Sections[0].Items.push({ ...form.Sections[0].Items[0], Id:'second', VisibleWhen:{ItemId:'first',EqualsValue:'fail'} }); + expect(visibleChecklistItems(form,[{ItemId:'first',Status:1,Value:'pass'}]).map(i=>i.Id)).toEqual(['first']); + expect(visibleChecklistItems(form,[{ItemId:'first',Status:1,Value:'fail'}]).map(i=>i.Id)).toEqual(['first','second']); + expect(isChecklistRequired({...form.Sections[0].Items[1],Required:false,RequiredWhen:{ItemId:'first',EqualsValue:'fail'}},[{ItemId:'first',Status:1,Value:'fail'}])).toBe(true); + }); + it('identifies critical and numeric failures without treating N/A as failure', () => { + const item = run().Form.Sections[0].Items[0]; expect(checklistFailed(item,{ItemId:item.Id,Status:1,Value:'fail'})).toBe(true); + expect(checklistFailed({...item,Type:3,Minimum:10},{ItemId:item.Id,Status:1,Value:'9'})).toBe(true); + expect(checklistFailed(item,{ItemId:item.Id,Status:2})).toBe(false); + }); +}); diff --git a/src/lib/checklists/__tests__/translations.test.ts b/src/lib/checklists/__tests__/translations.test.ts new file mode 100644 index 00000000..d82ac3a7 --- /dev/null +++ b/src/lib/checklists/__tests__/translations.test.ts @@ -0,0 +1,11 @@ +import fs from 'fs'; +import path from 'path'; +const root = path.resolve(__dirname, '../../../translations'); +const locales = ['en','de','el','es','fr','it','pl','sv','uk','ar']; +const read = (language: string): Record> => JSON.parse(fs.readFileSync(path.join(root, `${language}.json`),'utf8')).checklists; +const keys = (values: ReturnType) => Object.entries(values).flatMap(([section, values]) => Object.keys(values).map(key => `${section}.${key}`)).sort(); +it.each(locales)('provides the full checklist dictionary in %s without encoding corruption or placeholders', language => { + const translated = read(language); expect(keys(translated)).toEqual(keys(read('en'))); + for (const values of Object.values(translated)) for (const text of Object.values(values)) { expect(text.trim()).not.toBe(''); expect(text).not.toMatch(/\uFFFD|TODO|TRANSLATE_ME/); } + if (language !== 'en') { expect(translated.ui.queued).not.toBe(read('en').ui.queued); expect(translated.errors.locked).not.toBe(read('en').errors.locked); } +}); diff --git a/src/lib/checklists/__tests__/vault.test.ts b/src/lib/checklists/__tests__/vault.test.ts new file mode 100644 index 00000000..cc578b0b --- /dev/null +++ b/src/lib/checklists/__tests__/vault.test.ts @@ -0,0 +1,31 @@ +const mockDisk = new Map(); +const mockKeys = new Map(); +jest.mock('react-native', () => ({ Platform:{OS:'ios'} })); +jest.mock('react-native-mmkv', () => ({ MMKV:jest.fn().mockImplementation(()=>({getString:(k:string)=>mockDisk.get(k),set:(k:string,v:string)=>mockDisk.set(k,v),delete:(k:string)=>mockDisk.delete(k)})) })); +jest.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY:'device-only',getItemAsync:jest.fn(async(k:string)=>mockKeys.get(k)??null),setItemAsync:jest.fn(async(k:string,v:string)=>{mockKeys.set(k,v);}) })); +// Exercise authenticated encryption and AAD with Node's crypto, standing in for the native Expo module. +jest.mock('expo-crypto',()=>{ + const crypto=jest.requireActual('crypto'); + const bytes=(v:string|Uint8Array)=>typeof v==='string'?Buffer.from(v,'base64'):Buffer.from(v); + return { CryptoDigestAlgorithm:{SHA256:'sha256'},digestStringAsync:async(_:string,s:string)=>crypto.createHash('sha256').update(s).digest('hex'), + AESEncryptionKey:{generate:async()=>{const b=crypto.randomBytes(32);return {b,encoded:async()=>b.toString('base64')};},import:async(s:string)=>({b:Buffer.from(s,'base64')})}, + AESSealedData:{fromCombined:bytes}, + aesEncryptAsync:async(data:Uint8Array,key:{b:Buffer},options:{additionalData:Uint8Array})=>{ const iv=crypto.randomBytes(12);const cipher=crypto.createCipheriv('aes-256-gcm',key.b,iv);cipher.setAAD(bytes(options.additionalData));const payload=Buffer.concat([iv,cipher.update(bytes(data)),cipher.final(),cipher.getAuthTag()]);return {combined:async()=>payload.toString('base64')}; }, + aesDecryptAsync:async(data:Uint8Array,key:{b:Buffer},options:{additionalData:Uint8Array})=>{ const payload=bytes(data);const decipher=crypto.createDecipheriv('aes-256-gcm',key.b,payload.subarray(0,12));decipher.setAAD(bytes(options.additionalData));decipher.setAuthTag(payload.subarray(-16));return Buffer.concat([decipher.update(payload.subarray(12,-16)),decipher.final()]); }, + }; +}); +import * as SecureStore from 'expo-secure-store'; +import { vaultRead,vaultWrite } from '@/lib/checklists/vault'; + +it('persists only ciphertext, isolates tenants and uses a device-only OS key',async()=>{ + await vaultWrite('server|77|author','draft',{notes:'SYNTHETIC PHI',image:'synthetic-base64'}); + expect([...mockDisk.values()].join()).not.toContain('SYNTHETIC PHI');expect([...mockDisk.values()].join()).not.toContain('synthetic-base64'); + expect(await vaultRead('server|77|author','draft')).toEqual({notes:'SYNTHETIC PHI',image:'synthetic-base64'}); + expect(await vaultRead('server|88|author','draft')).toBeNull(); + expect(SecureStore.setItemAsync).toHaveBeenCalledWith(expect.any(String),expect.any(String),{keychainAccessible:'device-only'}); +}); +it('refuses tampered or relocated ciphertext without a plaintext fallback',async()=>{ + await vaultWrite('tamper-scope','first',{notes:'private'});const first=[...mockDisk.entries()].at(-1)!; + await vaultWrite('tamper-scope','second',{notes:'second'});const second=[...mockDisk.entries()].at(-1)!; + mockDisk.set(second[0],first[1]);await expect(vaultRead('tamper-scope','second')).rejects.toThrow(); +}); diff --git a/src/lib/checklists/form.ts b/src/lib/checklists/form.ts new file mode 100644 index 00000000..3d9e4be8 --- /dev/null +++ b/src/lib/checklists/form.ts @@ -0,0 +1,24 @@ +import type { ChecklistAnswer, ChecklistForm, ChecklistItem } from '@/models/v4/checklists'; + +export const visibleChecklistItems = (form: ChecklistForm, answers: ChecklistAnswer[]): ChecklistItem[] => { + const visible = new Set(); + return form.Sections.flatMap((s) => s.Items).filter((item) => { + const condition = item.VisibleWhen; + const answer = condition ? answers.find((a) => a.ItemId === condition.ItemId) : undefined; + const show = !condition || (visible.has(condition.ItemId) && answer?.Status === 1 && answer.Value === condition.EqualsValue); + if (show) visible.add(item.Id); + return show; + }); +}; +export const isChecklistRequired = (item: ChecklistItem, answers: ChecklistAnswer[]): boolean => + item.Required || (!!item.RequiredWhen && answers.some((a) => a.ItemId === item.RequiredWhen?.ItemId && a.Status === 1 && a.Value === item.RequiredWhen.EqualsValue)); +export const checklistFailed = (item: ChecklistItem, answer?: ChecklistAnswer): boolean => { + if (answer?.Status !== 1) return false; + if (item.Type === 0) return answer.Value === 'fail'; + if ([1, 2, 6].includes(item.Type)) return answer.Value !== item.PassingValue; + if ([3, 4].includes(item.Type)) { + const n = Number(answer.Value); + return !Number.isFinite(n) || (item.Minimum != null && n < item.Minimum) || (item.Maximum != null && n > item.Maximum); + } + return false; +}; diff --git a/src/lib/checklists/sync.ts b/src/lib/checklists/sync.ts new file mode 100644 index 00000000..f370f43b --- /dev/null +++ b/src/lib/checklists/sync.ts @@ -0,0 +1,56 @@ +import type { ChecklistDraft, ChecklistRun } from '@/models/v4/checklists'; + +export interface ChecklistSyncPort { + start: (draft: ChecklistDraft) => Promise; + upload: (draft: ChecklistDraft, index: number) => Promise; + save: (draft: ChecklistDraft) => Promise; + persist: (draft: ChecklistDraft) => Promise; + check: () => void; +} +/** Receipts are persisted after each step. Never replace a nonzero local revision with a fresh server read. */ +export const syncChecklistDraft = async (original: ChecklistDraft, port: ChecklistSyncPort): Promise => { + const draft: ChecklistDraft = JSON.parse(JSON.stringify(original)); + port.check(); + const started = await port.start(draft); + port.check(); + if (started.Id !== draft.id || started.VersionId !== draft.run.VersionId || (!started.CanEdit && started.State === 0)) throw new Error('checklist_conflict'); + if (draft.input.Revision === 0) { + if (started.Revision !== 1 || started.State !== 0) throw new Error('checklist_conflict'); + draft.input.Revision = 1; + draft.run = started; + await port.persist(draft); + } + // A final-response replay goes straight to the server's content-hash idempotency check. + if (started.State === 0) { + while (draft.images.length > 0) { + port.check(); + const receipt = await port.upload(draft, 0); + port.check(); + draft.input.Revision = receipt.Revision; + draft.run = receipt; + draft.images.shift(); + await port.persist(draft); + } + } else if (draft.images.length > 0) { + throw new Error('checklist_conflict'); + } + port.check(); + const receipt = await port.save(draft); + port.check(); + draft.run = receipt; + draft.input = receipt.Input; + draft.queued = false; + delete draft.error; + await port.persist(draft); + return draft; +}; + +export const checklistError = (error: unknown): string => { + const response = (error as { response?: { status?: number; data?: { type?: string } } })?.response; + if (response?.data?.type === 'protected_data_required') return 'locked'; + if (response?.status === 409 || (error instanceof Error && error.message === 'checklist_conflict')) return 'conflict'; + if (response?.status === 401 || response?.status === 403 || response?.status === 404) return 'denied'; + if (response?.status === 400 || response?.status === 413) return 'validation'; + if (error instanceof Error && ['locked', 'denied', 'storage_full'].includes(error.message)) return error.message; + return 'retry'; +}; diff --git a/src/lib/checklists/vault.ts b/src/lib/checklists/vault.ts new file mode 100644 index 00000000..8554c148 --- /dev/null +++ b/src/lib/checklists/vault.ts @@ -0,0 +1,55 @@ +import { Buffer } from 'buffer'; +import { aesDecryptAsync, aesEncryptAsync, AESEncryptionKey, AESSealedData, CryptoDigestAlgorithm, digestStringAsync } from 'expo-crypto'; +import * as SecureStore from 'expo-secure-store'; +import { Platform } from 'react-native'; +import { MMKV } from 'react-native-mmkv'; + +// Dedicated AES-GCM envelopes. Never use the app's shared/static MMKV key for checklist content. +// Native keys are device-only and available only while unlocked. Web has no persistent vault. +const native = Platform.OS !== 'web'; +const storage = native ? new MMKV({ id: 'checklists-ciphertext-v1' }) : null; +const memory = new Map(); +const keys = new Map>(); +const slot = async (scope: string, name: string) => digestStringAsync(CryptoDigestAlgorithm.SHA256, `checklists:v1:${scope}:${name}`); +const keyFor = (scope: string): Promise => { + let pending = keys.get(scope); + if (!pending) { + pending = (async () => { + const name = `checklists.${await slot(scope, 'key')}`; + const saved = native ? await SecureStore.getItemAsync(name, { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY }) : null; + if (saved) return AESEncryptionKey.import(saved, 'base64'); + const key = await AESEncryptionKey.generate(); + if (native) await SecureStore.setItemAsync(name, await key.encoded('base64'), { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY }); + return key; + })(); + keys.set(scope, pending); + const operation = pending; + pending + .finally(() => { + if (native && keys.get(scope) === operation) keys.delete(scope); + }) + .catch(() => keys.delete(scope)); + } + return pending; +}; +export const vaultRead = async (scope: string, name: string): Promise => { + const id = await slot(scope, name); + const ciphertext = native ? storage?.getString(id) : memory.get(id); + if (!ciphertext) return null; + const bytes = await aesDecryptAsync(AESSealedData.fromCombined(ciphertext), await keyFor(scope), { additionalData: Buffer.from(`${scope}:${name}`, 'utf8') }); + return JSON.parse(Buffer.from(bytes).toString('utf8')) as T; +}; +export const vaultWrite = async (scope: string, name: string, value: unknown): Promise => { + const plaintext = Buffer.from(JSON.stringify(value), 'utf8'); + if (plaintext.byteLength > 32 * 1024 * 1024) throw new Error('storage_full'); + const ciphertext = await aesEncryptAsync(plaintext, await keyFor(scope), { additionalData: Buffer.from(`${scope}:${name}`, 'utf8') }); + const id = await slot(scope, name); + const text = await ciphertext.combined('base64'); + if (native) storage!.set(id, text); + else memory.set(id, text); +}; +export const vaultRemove = async (scope: string, name: string): Promise => { + const id = await slot(scope, name); + if (native) storage!.delete(id); + else memory.delete(id); +}; diff --git a/src/lib/contacts/__tests__/format.test.ts b/src/lib/contacts/__tests__/format.test.ts new file mode 100644 index 00000000..2e3cd00d --- /dev/null +++ b/src/lib/contacts/__tests__/format.test.ts @@ -0,0 +1,27 @@ +import { htmlToText, mapsUrls, parseCoordinates, withoutRedacted } from '@/lib/contacts/format'; +import { type ContactResultData, ContactType } from '@/models/v4/contacts/contactResultData'; + +it('turns web-editor rich text into readable plain text', () => { + expect(htmlToText('

Knox box at north door

Gate & code

  • Hydrant
')).toBe('Knox box at north door\nGate & code\n• Hydrant'); + expect(htmlToText(null)).toBe(''); +}); + +it('parses coordinates and builds a maps link for an address or a point', () => { + expect(parseCoordinates('39.7392, -104.9903')).toEqual({ latitude: 39.7392, longitude: -104.9903 }); + expect(parseCoordinates('not a place')).toBeNull(); + expect(parseCoordinates('95, 10')).toBeNull(); + // The platform app link differs (maps: on iOS, geo: on Android); both carry the point or the encoded address. + expect(mapsUrls('39.7392,-104.9903').app).toMatch(/39\.7392,-104\.9903.*q=39\.7392%2C-104\.9903$/); + expect(mapsUrls('1 Main St, Denver').app).toMatch(/0,0\?q=1%20Main%20St%2C%20Denver$/); + expect(mapsUrls('1 Main St').web).toBe('https://maps.google.com/?q=1%20Main%20St'); +}); + +it('removes withheld values and names the fields so the sheet never shows or dials the sentinel', () => { + const contact = { ContactId: 'c-1', ContactType: ContactType.Person, FirstName: 'Ana', CellPhoneNumber: 'REDACTED', Email: 'REDACTED', IsDeleted: false } as unknown as ContactResultData; + const shown = withoutRedacted(contact); + expect(shown.CellPhoneNumber).toBeUndefined(); + expect(shown.Email).toBeUndefined(); + expect(shown.FirstName).toBe('Ana'); + expect(shown.WithheldFields).toEqual(['CellPhoneNumber', 'Email']); + expect(contact.CellPhoneNumber).toBe('REDACTED'); +}); diff --git a/src/lib/contacts/format.ts b/src/lib/contacts/format.ts new file mode 100644 index 00000000..08a49510 --- /dev/null +++ b/src/lib/contacts/format.ts @@ -0,0 +1,64 @@ +import { Platform } from 'react-native'; + +import { type ContactResultData } from '@/models/v4/contacts/contactResultData'; + +// Pure presentation helpers for a contact (the shared detail sheet in every field app). Nothing here decides +// what a person may see: withheld values arrive redacted from the server and are shown as such by the sheet. + +const ENTITIES: Record = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'", ' ': ' ' }; + +/** Rich text from the web editor as plain readable text: line breaks kept, markup and scripts dropped. */ +export const htmlToText = (html: string | null | undefined): string => { + if (!html) return ''; + return html + .replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, '') + .replace(//gi, '\n') + .replace(/<\/(p|div|li|h[1-6]|tr)>/gi, '\n') + .replace(/]*>/gi, '• ') + .replace(/<[^>]+>/g, '') + .replace(/&(amp|lt|gt|quot|#39|apos|nbsp);/g, (entity) => ENTITIES[entity] ?? entity) + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +}; + +/** "lat,lng" (or "lat lng") as numbers, when it parses. */ +export const parseCoordinates = (value: string | null | undefined): { latitude: number; longitude: number } | null => { + const match = /^\s*(-?\d+(?:\.\d+)?)\s*[, ]\s*(-?\d+(?:\.\d+)?)\s*$/.exec(value ?? ''); + if (!match) return null; + const latitude = Number(match[1]); + const longitude = Number(match[2]); + return Math.abs(latitude) <= 90 && Math.abs(longitude) <= 180 ? { latitude, longitude } : null; +}; + +/** The platform maps app for an address or a coordinate pair, with a web map as the fallback. */ +export const mapsUrls = (query: string) => { + const coordinates = parseCoordinates(query); + const encoded = encodeURIComponent(coordinates ? `${coordinates.latitude},${coordinates.longitude}` : query); + const web = `https://maps.google.com/?q=${encoded}`; + const app = Platform.select({ + ios: coordinates ? `maps:?ll=${coordinates.latitude},${coordinates.longitude}&q=${encoded}` : `maps:0,0?q=${encoded}`, + android: coordinates ? `geo:${coordinates.latitude},${coordinates.longitude}?q=${encoded}` : `geo:0,0?q=${encoded}`, + default: web, + }); + return { app, web }; +}; + +const REDACTED = 'REDACTED'; + +/** + * The contact with every withheld value removed, plus the names of the fields that were withheld. The detail + * sheet then never shows the wire sentinel as data or dials "tel:REDACTED"; it says what is protected and + * offers the reveal instead. The server's RedactedFields list marks catalog fields; the sentinel is the fallback. + */ +export const withoutRedacted = (contact: T): T & { WithheldFields: string[] } => { + const withheld: string[] = []; + const copy = { ...contact } as unknown as Record; + for (const [key, value] of Object.entries(contact)) { + if (value === REDACTED) { + copy[key] = undefined; + withheld.push(key); + } + } + return { ...(copy as T), WithheldFields: withheld }; +}; diff --git a/src/lib/inventory/__tests__/count.test.ts b/src/lib/inventory/__tests__/count.test.ts new file mode 100644 index 00000000..c8eb5322 --- /dev/null +++ b/src/lib/inventory/__tests__/count.test.ts @@ -0,0 +1,54 @@ +import { clampQuantity, contentOf, groupEquipment, inventoryError, isComplete, locationNames, orderLocations, pendingObservations, variances } from '@/lib/inventory/count'; +import type { InventoryCountLine } from '@/models/v4/inventory'; + +const line = (id: string, expected: number, patch: Partial = {}): InventoryCountLine => ({ Id: id, Revision: 0, CountId: 'c-1', ItemId: `i-${id}`, LocationId: 'loc-1', ExpectedQuantity: expected, CountedQuantity: null, ...patch }); + +it('reads row text from Content and never throws on withheld or malformed content', () => { + expect(contentOf('{"ItemName":"SCBA bottle","UnitOfMeasure":"each"}').ItemName).toBe('SCBA bottle'); + expect(contentOf('REDACTED')).toEqual({}); + expect(contentOf('{broken')).toEqual({}); + expect(contentOf(null)).toEqual({}); +}); + +it('orders the unit first and groups what it carries by compartment', () => { + const locations = [ + { Id: 'c', Name: 'L2', LocationType: 0, IsRoot: false }, + { Id: 'u', Name: 'Engine 41', LocationType: 2, IsRoot: true }, + { Id: 'b', Name: 'L1', LocationType: 0, IsRoot: false }, + ]; + expect(orderLocations(locations).map((location) => location.Id)).toEqual(['u', 'b', 'c']); + expect(locationNames([{ Id: 'x', Name: ' ', LocationType: 0, IsRoot: false }], 'Compartment')).toEqual({ x: 'Compartment' }); + const groups = groupEquipment( + [ + { ItemName: 'Hose 1.75"', Stock: { Id: 's-1', Revision: 0, ItemId: 'i-1', LocationId: 'b', Quantity: 4 }, Issuances: [] }, + { ItemName: 'Thermal camera', Asset: { Id: 'a-1', Revision: 0, ItemId: 'i-2', Status: 0, CurrentLocationId: 'u', Content: '{"SerialNumber":"TC-9"}' }, Issuances: [] }, + ], + 'Item' + ); + expect(groups.b[0]).toMatchObject({ name: 'Hose 1.75"', quantity: 4, assetStatus: null }); + expect(groups.u[0]).toMatchObject({ name: 'Thermal camera', quantity: 1, serial: 'TC-9', assetStatus: 0 }); +}); + +it('tracks progress, differences and only the changed observations', () => { + const lines = [line('1', 4), line('2', 1, { AssetId: 'a-1' }), line('3', 2, { CountedQuantity: 2 })]; + const observed = { '1': 3, '2': 1 }; + expect(isComplete(lines, observed)).toBe(true); + expect(isComplete(lines, { '1': 3 })).toBe(false); + expect(variances(lines, observed).map((row) => row.Id)).toEqual(['1']); + expect(pendingObservations(lines, { ...observed, '3': 2 })).toEqual([ + { Id: '1', Quantity: 3 }, + { Id: '2', Quantity: 1 }, + ]); + expect(clampQuantity(lines[1], 5)).toBe(1); + expect(clampQuantity(lines[1], 0.2)).toBe(0); + expect(clampQuantity(lines[0], -2)).toBe(0); + expect(clampQuantity(lines[0], 2.5)).toBe(2.5); +}); + +it('maps ProblemDetails codes', () => { + expect(inventoryError({ response: { status: 409, data: { type: 'inventory_CountSnapshotChanged', code: 'CountSnapshotChanged' } } })).toBe('CountSnapshotChanged'); + expect(inventoryError({ response: { status: 409, data: { type: 'inventory_CountScopeTooLarge' } } })).toBe('CountScopeTooLarge'); + expect(inventoryError({ response: { status: 403, data: { type: 'protected_data_required' } } })).toBe('locked'); + expect(inventoryError({ response: { status: 403 } })).toBe('PermissionRequired'); + expect(inventoryError(new Error('x'))).toBe('retry'); +}); diff --git a/src/lib/inventory/count.ts b/src/lib/inventory/count.ts new file mode 100644 index 00000000..2ceece21 --- /dev/null +++ b/src/lib/inventory/count.ts @@ -0,0 +1,98 @@ +import type { InventoryCountLine, InventoryEquipment, InventoryFieldLocation } from '@/models/v4/inventory'; + +// Pure helpers for the unit inventory screens. Row text lives in each row's Content JSON; a protected +// department without a grant sends it withheld, so parsing never throws and falls back to blanks. + +export interface ContentText { + Name?: string | null; + Note?: string | null; + ItemName?: string | null; + UnitOfMeasure?: string | null; + SerialNumber?: string | null; + LotNumber?: string | null; + AssetTag?: string | null; + VarianceLineCount?: number | null; +} + +export const contentOf = (content?: string | null): ContentText => { + if (!content || !content.trim().startsWith('{')) return {}; + try { + const parsed = JSON.parse(content); + return parsed && typeof parsed === 'object' ? (parsed as ContentText) : {}; + } catch { + return {}; + } +}; + +export const locationNames = (locations: InventoryFieldLocation[], fallback: string) => + locations.reduce>((names, location) => { + names[location.Id] = location.Name?.trim() || fallback; + return names; + }, {}); + +/** The unit location first, then its compartments by name. */ +export const orderLocations = (locations: InventoryFieldLocation[]) => [...locations].sort((a, b) => Number(b.IsRoot) - Number(a.IsRoot) || (a.Name ?? '').localeCompare(b.Name ?? '')); + +export interface EquipmentRow { + key: string; + locationId: string; + name: string; + quantity: number; + serial?: string | null; + assetStatus?: number | null; + issuedCount: number; +} + +/** What the unit carries, one row per stock position or serialized asset, grouped by where it sits. */ +export const groupEquipment = (equipment: InventoryEquipment[], fallbackName: string) => { + const groups: Record = {}; + equipment.forEach((row, index) => { + const locationId = row.Asset?.CurrentLocationId ?? row.Stock?.LocationId ?? ''; + const details = contentOf(row.Asset?.Content); + const item: EquipmentRow = { + key: row.Asset?.Id ?? row.Stock?.Id ?? String(index), + locationId, + name: row.ItemName?.trim() || fallbackName, + quantity: row.Asset ? 1 : (row.Stock?.Quantity ?? 0), + serial: details.SerialNumber ?? details.AssetTag ?? null, + assetStatus: row.Asset ? row.Asset.Status : null, + issuedCount: row.Issuances?.length ?? 0, + }; + (groups[locationId] ??= []).push(item); + }); + Object.values(groups).forEach((rows) => rows.sort((a, b) => a.name.localeCompare(b.name))); + return groups; +}; + +/** Counted values the person has entered, falling back to what the server already holds. */ +export const observedQuantity = (line: InventoryCountLine, observed: Record) => observed[line.Id] ?? line.CountedQuantity ?? null; + +export const isComplete = (lines: InventoryCountLine[], observed: Record) => lines.length > 0 && lines.every((line) => observedQuantity(line, observed) != null); + +export const variances = (lines: InventoryCountLine[], observed: Record) => + lines.filter((line) => { + const counted = observedQuantity(line, observed); + return counted != null && counted !== line.ExpectedQuantity; + }); + +/** Only the lines whose counted value changed from what the server holds, in the server's 100-line batches. */ +export const pendingObservations = (lines: InventoryCountLine[], observed: Record) => + lines.filter((line) => observed[line.Id] != null && observed[line.Id] !== line.CountedQuantity).map((line) => ({ Id: line.Id, Quantity: observed[line.Id] })); + +export const clampQuantity = (line: InventoryCountLine, value: number) => { + if (!Number.isFinite(value) || value < 0) return 0; + // A serialized asset is either there (1) or not (0). + if (line.AssetId) return value >= 1 ? 1 : 0; + return Math.round(value * 1000000) / 1000000; +}; + +/** A server refusal as its ProblemDetails code ("inventory_CountSnapshotChanged" → "CountSnapshotChanged"). */ +export const inventoryError = (error: unknown): string => { + const response = (error as { response?: { status?: number; data?: { type?: string; code?: string } } })?.response; + const type = String(response?.data?.type ?? ''); + if (type === 'protected_data_required') return 'locked'; + if (response?.data?.code) return String(response.data.code); + if (type.startsWith('inventory_')) return type.slice('inventory_'.length); + if (response?.status === 401 || response?.status === 403 || response?.status === 404) return 'PermissionRequired'; + return 'retry'; +}; diff --git a/src/lib/media/photo.ts b/src/lib/media/photo.ts new file mode 100644 index 00000000..86107158 --- /dev/null +++ b/src/lib/media/photo.ts @@ -0,0 +1,47 @@ +import * as FileSystem from 'expo-file-system/legacy'; +import { manipulateAsync, SaveFormat } from 'expo-image-manipulator'; +import * as ImagePicker from 'expo-image-picker'; + +// One photo from the camera or the library, normalised for upload: HEIC and other camera formats become +// JPEG, the long side is bounded, and metadata (GPS, device) is dropped by the re-encode. Receipts, work +// order photos and certificate scans all go through here. Cache copies are removed; a camera-roll original +// never is. + +export interface CapturedPhoto { + base64: string; + uri: string; + name: string; + contentType: 'image/jpeg'; + width: number; + height: number; +} + +export class PhotoPermissionError extends Error { + constructor() { + super('denied'); + } +} + +const MAX_EDGE = 2048; + +export const capturePhoto = async (source: 'camera' | 'library', name = `photo-${Date.now()}.jpg`): Promise => { + const permission = await (source === 'camera' ? ImagePicker.requestCameraPermissionsAsync() : ImagePicker.requestMediaLibraryPermissionsAsync()); + if (!permission.granted) throw new PhotoPermissionError(); + const result = await (source === 'camera' ? ImagePicker.launchCameraAsync({ quality: 0.8, exif: false }) : ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 0.8, exif: false })); + if (result.canceled || !result.assets?.[0]) return null; + const asset = result.assets[0]; + let converted: { uri: string; base64?: string; width: number; height: number } | null = null; + try { + const resize = asset.width >= asset.height ? { width: Math.min(asset.width || MAX_EDGE, MAX_EDGE) } : { height: Math.min(asset.height || MAX_EDGE, MAX_EDGE) }; + converted = await manipulateAsync(asset.uri, [{ resize }], { format: SaveFormat.JPEG, compress: 0.8, base64: true }); + if (!converted.base64) return null; + return { base64: converted.base64, uri: converted.uri, name: name.toLowerCase().endsWith('.jpg') ? name : `${name}.jpg`, contentType: 'image/jpeg', width: converted.width, height: converted.height }; + } finally { + if (FileSystem.cacheDirectory && asset.uri.startsWith(FileSystem.cacheDirectory) && asset.uri !== converted?.uri) await FileSystem.deleteAsync(asset.uri, { idempotent: true }).catch(() => undefined); + } +}; + +/** Removes an app cache copy made by {@link capturePhoto} once it has been uploaded. */ +export const discardPhoto = async (photo: CapturedPhoto | null | undefined) => { + if (photo?.uri && FileSystem.cacheDirectory && photo.uri.startsWith(FileSystem.cacheDirectory)) await FileSystem.deleteAsync(photo.uri, { idempotent: true }).catch(() => undefined); +}; diff --git a/src/lib/notifications/__tests__/inbox-reference.test.ts b/src/lib/notifications/__tests__/inbox-reference.test.ts new file mode 100644 index 00000000..8084c6be --- /dev/null +++ b/src/lib/notifications/__tests__/inbox-reference.test.ts @@ -0,0 +1,34 @@ +import { referenceFromEventCode, referenceHref } from '../inbox-reference'; + +describe('inbox references', () => { + it('reads call dispatch codes, which carry no separator', () => { + expect(referenceFromEventCode('C1234')).toEqual({ referenceType: 'call', referenceId: '1234' }); + expect(referenceFromEventCode('c:77')).toEqual({ referenceType: 'call', referenceId: '77' }); + }); + + it('does not read a communication-test token as a call', () => { + expect(referenceFromEventCode('CT:9f2c')).toBeUndefined(); + }); + + it('reads direct and group chat codes', () => { + expect(referenceFromEventCode('t:9a2b')).toEqual({ referenceType: 'chat', referenceId: '9a2b' }); + expect(referenceFromEventCode('G:7f1c')).toEqual({ referenceType: 'chat', referenceId: '7f1c' }); + }); + + it('refuses ids that could steer the router and codes it has no screen for', () => { + expect(referenceFromEventCode('g:../call/9')).toBeUndefined(); + expect(referenceFromEventCode('t:a?x=1')).toBeUndefined(); + expect(referenceFromEventCode('NWO:0b7c3e52-2f4a-4d0e-9a57-1f7a0c9d6e11')).toBeUndefined(); + expect(referenceFromEventCode('M5678')).toBeUndefined(); + expect(referenceFromEventCode('')).toBeUndefined(); + expect(referenceFromEventCode(undefined)).toBeUndefined(); + expect(referenceFromEventCode(42)).toBeUndefined(); + }); + + it('builds the route for each reference and refuses unsafe ids', () => { + expect(referenceHref('call', '1234')).toEqual({ pathname: '/call/[id]', params: { id: '1234' } }); + expect(referenceHref('chat', '7f1c')).toEqual({ pathname: '/chat/[channelId]', params: { channelId: '7f1c' } }); + expect(referenceHref('chat', '../settings')).toBeNull(); + expect(referenceHref('note', 'n-1')).toBeNull(); + }); +}); diff --git a/src/lib/notifications/inbox-reference.ts b/src/lib/notifications/inbox-reference.ts new file mode 100644 index 00000000..abb47c40 --- /dev/null +++ b/src/lib/notifications/inbox-reference.ts @@ -0,0 +1,32 @@ +import type { Href } from 'expo-router'; + +import { isSafeRouteId, parseNotificationData } from '@/stores/push-notification/store'; + +export type InboxReferenceType = 'call' | 'chat'; + +export interface InboxReference { + referenceType: InboxReferenceType; + referenceId: string; +} + +/** + * The item a Novu inbox notification links to, read from the push event code the Novu bridge copies + * into the in-app `data`: "C{callId}" for call dispatches, "t:{channelId}" / "g:{channelId}" for chat. + * Ids that could steer the router elsewhere are refused, as the push handlers do. + */ +export const referenceFromEventCode = (eventCode: unknown): InboxReference | undefined => { + if (typeof eventCode !== 'string') return undefined; + const call = /^C:?(\d+)$/i.exec(eventCode); + if (call) return { referenceType: 'call', referenceId: call[1] }; + const parsed = parseNotificationData({ eventCode }); + if ((parsed.type === 'chat' || parsed.type === 'group-chat') && isSafeRouteId(parsed.id)) return { referenceType: 'chat', referenceId: parsed.id }; + return undefined; +}; + +/** The screen an inbox reference opens, or null when this app has none for it. */ +export const referenceHref = (referenceType: string, referenceId: string): Href | null => { + if (!isSafeRouteId(referenceId)) return null; + if (referenceType === 'call') return { pathname: '/call/[id]', params: { id: referenceId } }; + if (referenceType === 'chat') return { pathname: '/chat/[channelId]', params: { channelId: referenceId } }; + return null; +}; diff --git a/src/lib/operations/__tests__/time.test.ts b/src/lib/operations/__tests__/time.test.ts new file mode 100644 index 00000000..a980db59 --- /dev/null +++ b/src/lib/operations/__tests__/time.test.ts @@ -0,0 +1,141 @@ +import { + applyToCrew, + availableScopes, + combineDateTime, + coveringReport, + defaultScope, + endLocalFor, + entryHours, + hoursBySubject, + isClockTime, + isReportEditable, + newEntry, + operationsError, + reportForScope, + scopeKey, + scopeSubjects, + shiftDateKey, + sumHours, + timeOf, + validateEntries, +} from '@/lib/operations/time'; +import type { Deployment, TimeEntry, TimeReport } from '@/models/v4/operations'; + +// Engine 41 (me as crew boss, a crew member, a released member, a pump), Engine 42 (another crew, a saw), +// a single resource with no unit. TimeAccess is what the server answers for "me". +const deployment = (access: Partial> = {}): Deployment => ({ + Id: 'dep-1', + Name: 'LNU Lightning Complex', + Status: 2, + FinanceMode: 1, + Units: [ + { Id: 'du-1', UnitId: 12, UnitName: 'E-41', CallSign: null, IsActive: true }, + { Id: 'du-2', UnitId: 13, UnitName: 'E-42', CallSign: null, IsActive: true }, + { Id: 'du-3', UnitId: 14, UnitName: 'E-43', CallSign: null, IsActive: false }, + ], + Personnel: [ + { Id: 'dp-1', UserId: 'me', Name: 'Me', DeploymentUnitId: 'du-1', IsActive: true }, + { Id: 'dp-2', UserId: 'crew', Name: 'Crew', DeploymentUnitId: 'du-1', IsActive: true, CallSign: 'FF' }, + { Id: 'dp-3', UserId: 'other', Name: 'Other', DeploymentUnitId: 'du-2', IsActive: true }, + { Id: 'dp-4', UserId: 'gone', Name: 'Gone', DeploymentUnitId: 'du-1', IsActive: false }, + { Id: 'dp-5', UserId: 'solo', Name: 'Solo', DeploymentUnitId: null, IsActive: true }, + ], + Equipment: [ + { Id: 'de-1', DeploymentUnitId: 'du-1', Name: 'Pump', IsActive: true }, + { Id: 'de-2', DeploymentUnitId: 'du-2', Name: 'Saw', IsActive: true }, + ], + TimeAccess: { CanManage: false, CanApprove: false, PersonnelId: 'dp-1', CrewUnitIds: ['du-1'], WritableSubjectIds: ['dp-1', 'du-1', 'dp-2', 'dp-4', 'de-1'], TimeZone: 'Pacific Standard Time', ...access }, +}); + +const entry = (patch: Partial): TimeEntry => ({ ...newEntry({ type: 0, id: 'dp-1', label: 'Me' }, '2026-09-19', 0), ...patch }); +const report = (patch: Partial): TimeReport => ({ Id: 'r-1', DeploymentId: 'dep-1', ReportNumber: 1, ReportDate: '2026-09-19T00:00:00', Scope: 1, DeploymentUnitId: 'du-1', CanAct: true, Status: 0, Entries: [], ...patch }); + +it('computes hours from department-local wall clock minus unpaid breaks, across midnight too', () => { + expect(timeOf('2026-09-19T07:30')).toBe('07:30'); + expect(isClockTime('7:30')).toBe(false); + expect(isClockTime('23:59')).toBe(true); + expect(combineDateTime('2026-09-19', '07:30')).toBe('2026-09-19T07:30'); + expect(shiftDateKey('2026-12-31', 1)).toBe('2027-01-01'); + expect(endLocalFor('2026-09-19T20:00', '06:00')).toBe('2026-09-20T06:00'); + expect(endLocalFor('2026-09-19T08:00', '18:00')).toBe('2026-09-19T18:00'); + expect(entryHours(entry({ StartLocal: '2026-09-19T08:00', EndLocal: '2026-09-19T18:00', UnpaidBreakMinutes: 30 }))).toBe(9.5); + expect(entryHours(entry({ StartLocal: '2026-09-19T20:00', EndLocal: '2026-09-20T06:00' }))).toBe(10); + expect(entryHours(entry({ StartLocal: '2026-09-19T18:00', EndLocal: '2026-09-19T08:00' }))).toBe(0); + expect(sumHours([entry({ StartLocal: '2026-09-19T08:00', EndLocal: '2026-09-19T12:15' }), entry({ StartLocal: '2026-09-19T13:00', EndLocal: '2026-09-19T14:00' })])).toBe(5.25); +}); + +it('flags missing subjects, inverted times and overlapping entries for the same subject only', () => { + const issues = validateEntries([ + entry({ DeploymentPersonnelId: null }), + entry({ StartLocal: '2026-09-19T18:00', EndLocal: '2026-09-19T08:00' }), + entry({ StartLocal: '2026-09-19T08:00', EndLocal: '2026-09-19T12:00' }), + entry({ StartLocal: '2026-09-19T11:00', EndLocal: '2026-09-19T13:00' }), + entry({ DeploymentPersonnelId: 'dp-2', StartLocal: '2026-09-19T11:00', EndLocal: '2026-09-19T13:00' }), + ]); + expect(issues).toEqual([ + { code: 'missing_subject', index: 0 }, + { code: 'end_before_start', index: 1 }, + { code: 'overlap', index: 3 }, + ]); +}); + +it('offers exactly the scopes the server granted and opens on the crew report', () => { + expect(availableScopes(deployment()).map(scopeKey)).toEqual(['crew:du-1', 'person:dp-1']); + expect(scopeKey(defaultScope(deployment(), null))).toBe('crew:du-1'); + // The Engine 42 tablet: seated on unit 13, not on the roster. + const tablet = deployment({ PersonnelId: null, CrewUnitIds: ['du-2'], WritableSubjectIds: ['du-2', 'dp-3', 'de-2'] }); + expect(scopeKey(defaultScope(tablet, '13'))).toBe('crew:du-2'); + // A single resource files their own report. + const solo = deployment({ PersonnelId: 'dp-5', CrewUnitIds: [], WritableSubjectIds: ['dp-5'] }); + expect(availableScopes(solo).map(scopeKey)).toEqual(['person:dp-5']); + expect(scopeKey(defaultScope(solo, null))).toBe('person:dp-5'); + // A manager may open every active crew, their own and the deployment-wide DTR. + const manager = deployment({ CanManage: true, PersonnelId: null, CrewUnitIds: [], WritableSubjectIds: [] }); + expect(availableScopes(manager).map(scopeKey)).toEqual(['crew:du-1', 'crew:du-2', 'deployment']); + expect(availableScopes({ ...deployment(), TimeAccess: null })).toEqual([]); +}); + +it('limits the picker to the report scope and to subjects the person may write', () => { + expect(scopeSubjects(deployment(), { kind: 'crew', unitId: 'du-1' }).map((subject) => subject.id)).toEqual(['du-1', 'dp-1', 'dp-2', 'de-1']); + expect(scopeSubjects(deployment(), { kind: 'crew', unitId: 'du-1' }).find((subject) => subject.id === 'dp-2')?.label).toBe('Crew (FF)'); + expect(scopeSubjects(deployment(), { kind: 'crew', unitId: 'du-2' })).toEqual([]); + expect(scopeSubjects(deployment(), { kind: 'individual', personnelId: 'dp-1' }).map((subject) => subject.id)).toEqual(['dp-1']); + expect(scopeSubjects(deployment({ CanManage: true }), { kind: 'deployment' }).map((subject) => subject.id)).toEqual(['du-1', 'du-2', 'dp-1', 'dp-2', 'dp-3', 'dp-5', 'de-1', 'de-2']); +}); + +it('copies the unit row onto the crew only for writable rows of the same entry type', () => { + const rows = [ + entry({ SubjectType: 1, DeploymentPersonnelId: null, DeploymentUnitId: 'du-1', StartLocal: '2026-09-19T06:00', EndLocal: '2026-09-19T20:00', UnpaidBreakMinutes: 30, AgencySuppliedMeals: true }), + entry({ DeploymentPersonnelId: 'dp-2' }), + entry({ DeploymentPersonnelId: 'dp-3' }), + entry({ DeploymentPersonnelId: 'dp-1', EntryType: 2 }), + ]; + const applied = applyToCrew(rows, 0, (id) => id !== 'dp-3'); + expect(applied[1]).toMatchObject({ StartLocal: '2026-09-19T06:00', EndLocal: '2026-09-19T20:00', UnpaidBreakMinutes: 30, AgencySuppliedMeals: true }); + expect(applied[2]).toBe(rows[2]); + expect(applied[3]).toBe(rows[3]); + expect(hoursBySubject(applied)).toEqual({ 'du-1': 13.5, 'dp-2': 13.5, 'dp-3': 10, 'dp-1': 10 }); +}); + +it('finds the report for a scope and day, the report already covering a subject, and editability', () => { + const crew = report({ Entries: [entry({ DeploymentPersonnelId: 'dp-1' })] }); + const solo = report({ Id: 'r-2', Scope: 2, DeploymentUnitId: null, DeploymentPersonnelId: 'dp-5' }); + const voided = report({ Id: 'r-3', Status: 4 }); + expect(reportForScope([voided, crew, solo], '2026-09-19', { kind: 'crew', unitId: 'du-1' })?.Id).toBe('r-1'); + expect(reportForScope([crew, solo], '2026-09-19', { kind: 'individual', personnelId: 'dp-5' })?.Id).toBe('r-2'); + expect(reportForScope([crew], '2026-09-20', { kind: 'crew', unitId: 'du-1' })).toBeNull(); + expect(coveringReport([crew, solo], '2026-09-19', 'dp-1')?.Id).toBe('r-1'); + expect(coveringReport([crew], '2026-09-19', 'dp-1', 'r-1')).toBeNull(); + expect(isReportEditable(crew)).toBe(true); + expect(isReportEditable({ ...crew, CanAct: false })).toBe(false); + expect(isReportEditable({ ...crew, Status: 1 })).toBe(false); +}); + +it('maps server failures to short reason codes', () => { + expect(operationsError({ response: { status: 403, headers: { 'x-resgrid-reason': 'deployments_disabled' } } })).toBe('disabled'); + expect(operationsError({ response: { status: 401 } })).toBe('denied'); + expect(operationsError({ response: { status: 400, headers: { 'x-resgrid-reason': 'timereports_subject_covered' } } })).toBe('timereports_subject_covered'); + expect(operationsError({ response: { status: 400 } })).toBe('validation'); + expect(operationsError({ response: { status: 200, data: { type: 'protected_data_required' } } })).toBe('locked'); + expect(operationsError(new Error('boom'))).toBe('retry'); +}); diff --git a/src/lib/operations/capabilities.ts b/src/lib/operations/capabilities.ts new file mode 100644 index 00000000..6300fafa --- /dev/null +++ b/src/lib/operations/capabilities.ts @@ -0,0 +1,20 @@ +import type { Href } from 'expo-router'; + +import { useCoreStore } from '@/stores/app/core-store'; + +// What this app does with a deployment. The server enforces every rule again; this only shapes the UI. +// The apparatus tablet files its crew time report, fuel and usage; approval stays with supervisors. +export const operationsCapabilities = { + /** Write crew / individual time reports (the active unit's Crew Time Report for the seated crew). */ + editTime: true, + /** Record odometer / engine / fuel readings against a deployment unit. */ + recordUsage: true, + /** Add expenses (meals, fuel, lodging) with a receipt photo. */ + recordExpenses: true, + /** Approve submitted time reports when the person holds TimeReports_Approve. */ + approveTime: false, + /** Draft and validate the CAL OES MARS F-42 from the field. */ + draftF42: true, + homeRoute: '/(app)' as Href, + useActiveUnitId: (): string | null => useCoreStore((state) => state.activeUnitId), +}; diff --git a/src/lib/operations/time.ts b/src/lib/operations/time.ts new file mode 100644 index 00000000..b9085e07 --- /dev/null +++ b/src/lib/operations/time.ts @@ -0,0 +1,244 @@ +import type { Deployment, TimeEntry, TimeReport } from '@/models/v4/operations'; +import { TimeEntryType, TimeReportScope, TimeReportStatus, TimeSubjectType } from '@/models/v4/operations'; + +// Pure helpers behind the crew / individual time report editor. Entry times are the department-local wall +// clock the crew writes on a paper time report ("yyyy-MM-ddTHH:mm", StartLocal/EndLocal); the server does +// the zone conversion, so nothing here touches UTC or the device's time zone. + +const pad = (n: number) => String(n).padStart(2, '0'); + +export const localDateKey = (date = new Date()) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + +export const shiftDateKey = (dateKey: string, days: number) => { + const [y, m, d] = dateKey.split('-').map(Number); + return localDateKey(new Date(y, m - 1, d + days)); +}; + +export const timeOf = (value: string | null | undefined) => { + const match = /T(\d{2}):(\d{2})/.exec(value ?? ''); + return match ? `${match[1]}:${match[2]}` : ''; +}; + +export const dateOf = (value: string | null | undefined) => /^(\d{4}-\d{2}-\d{2})/.exec(value ?? '')?.[1] ?? ''; + +export const isClockTime = (value: string) => /^([01]\d|2[0-3]):[0-5]\d$/.test(value); + +export const combineDateTime = (dateKey: string, clock: string) => `${dateKey}T${clock}`; + +const minutesOf = (value: string | null | undefined) => { + const match = /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value ?? ''); + if (!match) return null; + const day = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])); + return day / 60000 + Number(match[4]) * 60 + Number(match[5]); +}; + +/** An end clock at or before the start clock is the next morning (a shift across midnight). */ +export const endLocalFor = (startLocal: string, clock: string) => { + const day = dateOf(startLocal); + return clock <= timeOf(startLocal) ? combineDateTime(shiftDateKey(day, 1), clock) : combineDateTime(day, clock); +}; + +export const entryMinutes = (entry: Pick) => { + const start = minutesOf(entry.StartLocal); + const end = minutesOf(entry.EndLocal); + if (start == null || end == null || end < start) return 0; + return Math.max(0, end - start - (entry.UnpaidBreakMinutes || 0)); +}; + +export const entryHours = (entry: Pick) => Math.round((entryMinutes(entry) / 60) * 100) / 100; + +export const sumHours = (entries: TimeEntry[]) => Math.round(entries.reduce((total, entry) => total + entryMinutes(entry), 0) / 0.6) / 100; + +export const subjectIdOf = (entry: Pick) => entry.DeploymentPersonnelId ?? entry.DeploymentUnitId ?? entry.DeploymentEquipmentId ?? ''; + +export const subjectKey = (entry: TimeEntry) => `${entry.SubjectType}:${subjectIdOf(entry)}`; + +export interface EntryIssue { + code: 'missing_subject' | 'missing_time' | 'end_before_start' | 'overlap'; + index: number; +} + +export const validateEntries = (entries: TimeEntry[]): EntryIssue[] => { + const issues: EntryIssue[] = []; + const seen: { key: string; start: number; end: number; index: number }[] = []; + entries.forEach((entry, index) => { + if (!subjectIdOf(entry)) issues.push({ code: 'missing_subject', index }); + const start = minutesOf(entry.StartLocal); + const end = minutesOf(entry.EndLocal); + if (start == null || end == null) { + issues.push({ code: 'missing_time', index }); + return; + } + if (end <= start) { + issues.push({ code: 'end_before_start', index }); + return; + } + const key = subjectKey(entry); + if (seen.some((other) => other.key === key && other.start < end && start < other.end)) issues.push({ code: 'overlap', index }); + seen.push({ key, start, end, index }); + }); + return issues; +}; + +export interface EntrySubject { + type: number; + id: string; + label: string; +} + +export const newEntry = (subject: EntrySubject, dateKey: string, sortOrder: number, template?: Partial): TimeEntry => ({ + Id: null, + SubjectType: subject.type, + DeploymentPersonnelId: subject.type === TimeSubjectType.Personnel ? subject.id : null, + DeploymentUnitId: subject.type === TimeSubjectType.Unit ? subject.id : null, + DeploymentEquipmentId: subject.type === TimeSubjectType.Equipment ? subject.id : null, + EntryType: template?.EntryType ?? TimeEntryType.Deployment, + StartLocal: template?.StartLocal ?? combineDateTime(dateKey, '08:00'), + EndLocal: template?.EndLocal ?? combineDateTime(dateKey, '18:00'), + PaidBreakMinutes: template?.PaidBreakMinutes ?? 0, + UnpaidBreakMinutes: template?.UnpaidBreakMinutes ?? 0, + CrewSizeSnapshot: null, + CertificationCode: null, + MileageKm: null, + FuelDeductionLitres: null, + AgencySuppliedMeals: template?.AgencySuppliedMeals ?? false, + AgencySuppliedAccommodation: template?.AgencySuppliedAccommodation ?? false, + Notes: null, + SortOrder: sortOrder, +}); + +// --------------------------------------------------------------------------------------------------- +// Report scopes. A deployed unit files one Crew Time Report a day for the unit, its crew and its +// equipment; a single resource files an individual report; a manager may keep the deployment-wide DTR. +// --------------------------------------------------------------------------------------------------- + +export type TimeScope = { kind: 'crew'; unitId: string } | { kind: 'individual'; personnelId: string } | { kind: 'deployment' }; + +export const scopeKey = (scope: TimeScope | null | undefined) => (!scope ? '' : scope.kind === 'crew' ? `crew:${scope.unitId}` : scope.kind === 'individual' ? `person:${scope.personnelId}` : 'deployment'); + +export const reportScope = (report: Pick): TimeScope => + report.Scope === TimeReportScope.Crew && report.DeploymentUnitId + ? { kind: 'crew', unitId: report.DeploymentUnitId } + : report.Scope === TimeReportScope.Individual && report.DeploymentPersonnelId + ? { kind: 'individual', personnelId: report.DeploymentPersonnelId } + : { kind: 'deployment' }; + +// A report's date arrives as a date-time; match on the calendar day only. +export const reportsOnDate = (reports: TimeReport[], dateKey: string) => reports.filter((report) => dateOf(report.ReportDate) === dateKey && report.Status !== TimeReportStatus.Void); + +export const reportForScope = (reports: TimeReport[], dateKey: string, scope: TimeScope | null) => + scope ? (reportsOnDate(reports, dateKey).find((report) => scopeKey(reportScope(report)) === scopeKey(scope)) ?? null) : null; + +/** The report that already carries this subject's time today, if it is not the one being edited. */ +export const coveringReport = (reports: TimeReport[], dateKey: string, subjectId: string, exceptReportId?: string | null) => + reportsOnDate(reports, dateKey).find((report) => report.Id !== exceptReportId && report.Entries.some((entry) => subjectIdOf(entry) === subjectId)) ?? null; + +/** The scopes this person may open, straight from the server's TimeAccess (never inferred on the device). */ +export const availableScopes = (deployment: Deployment): TimeScope[] => { + const access = deployment.TimeAccess; + if (!access) return []; + const activeUnits = new Set(deployment.Units.filter((unit) => unit.IsActive).map((unit) => unit.Id)); + const scopes: TimeScope[] = []; + const crewUnits = access.CanManage ? deployment.Units.filter((unit) => unit.IsActive).map((unit) => unit.Id) : access.CrewUnitIds.filter((id) => activeUnits.has(id)); + crewUnits.forEach((unitId) => scopes.push({ kind: 'crew', unitId })); + if (access.PersonnelId) scopes.push({ kind: 'individual', personnelId: access.PersonnelId }); + if (access.CanManage) scopes.push({ kind: 'deployment' }); + return scopes; +}; + +/** + * The scope a screen opens on: the active unit's crew report on the unit tablet; otherwise the crew the person + * is seated on (the crew boss files the CTR); otherwise their own report; otherwise a manager's first scope. + */ +export const defaultScope = (deployment: Deployment, activeUnitId: string | null): TimeScope | null => { + const scopes = availableScopes(deployment); + if (activeUnitId) { + const unit = deployment.Units.find((candidate) => candidate.IsActive && String(candidate.UnitId) === activeUnitId); + const crew = unit ? scopes.find((scope) => scope.kind === 'crew' && scope.unitId === unit.Id) : undefined; + if (crew) return crew; + } + const own = deployment.TimeAccess?.PersonnelId ? deployment.Personnel.find((person) => person.Id === deployment.TimeAccess?.PersonnelId) : undefined; + const ownCrew = own?.DeploymentUnitId ? scopes.find((scope) => scope.kind === 'crew' && scope.unitId === own.DeploymentUnitId) : undefined; + if (ownCrew) return ownCrew; + if (!deployment.TimeAccess?.CanManage) { + const seated = scopes.find((scope) => scope.kind === 'crew'); + if (seated) return seated; + } + return scopes.find((scope) => scope.kind === 'individual') ?? scopes[0] ?? null; +}; + +const named = (name: string, callSign?: string | null) => (callSign ? `${name} (${callSign})` : name); + +export const canWriteSubject = (deployment: Deployment, subjectId: string) => !!deployment.TimeAccess?.CanManage || (deployment.TimeAccess?.WritableSubjectIds ?? []).includes(subjectId); + +/** Who or what a report in this scope may carry that the person may write: the picker for new rows. */ +export const scopeSubjects = (deployment: Deployment, scope: TimeScope | null): EntrySubject[] => { + if (!scope) return []; + const inUnit = (unitId?: string | null) => scope.kind === 'deployment' || (scope.kind === 'crew' && unitId === scope.unitId); + const people = deployment.Personnel.filter((person) => person.IsActive && (scope.kind === 'individual' ? person.Id === scope.personnelId : inUnit(person.DeploymentUnitId))); + const units = scope.kind === 'individual' ? [] : deployment.Units.filter((unit) => unit.IsActive && inUnit(unit.Id)); + const equipment = scope.kind === 'individual' ? [] : deployment.Equipment.filter((item) => item.IsActive && inUnit(item.DeploymentUnitId)); + return [ + ...units.map((unit) => ({ type: TimeSubjectType.Unit, id: unit.Id, label: named(unit.UnitName, unit.CallSign) })), + ...people.map((person) => ({ type: TimeSubjectType.Personnel, id: person.Id, label: named(person.Name, person.CallSign) })), + ...equipment.map((item) => ({ type: TimeSubjectType.Equipment, id: item.Id, label: item.Name })), + ].filter((subject) => canWriteSubject(deployment, subject.id)); +}; + +/** Every roster name by subject id (active or since released), so a stored row always has a label. */ +export const subjectNames = (deployment: Deployment) => { + const names: Record = {}; + deployment.Units.forEach((unit) => (names[unit.Id] = named(unit.UnitName, unit.CallSign))); + deployment.Personnel.forEach((person) => (names[person.Id] = named(person.Name, person.CallSign))); + deployment.Equipment.forEach((item) => (names[item.Id] = item.Name)); + return names; +}; + +export const scopeName = (deployment: Deployment, scope: TimeScope | null) => { + if (!scope || scope.kind === 'deployment') return ''; + return subjectNames(deployment)[scope.kind === 'crew' ? scope.unitId : scope.personnelId] ?? ''; +}; + +/** + * The CTR shortcut: most crews work the unit's shift together, so copy one row's times, breaks and agency + * supplies onto every other row of the same entry type the person may write. Rows that differ are then + * edited one by one. + */ +export const applyToCrew = (entries: TimeEntry[], sourceIndex: number, writable: (subjectId: string) => boolean): TimeEntry[] => { + const source = entries[sourceIndex]; + if (!source) return entries; + return entries.map((entry, index) => + index === sourceIndex || entry.EntryType !== source.EntryType || !writable(subjectIdOf(entry)) + ? entry + : { + ...entry, + StartLocal: source.StartLocal, + EndLocal: source.EndLocal, + PaidBreakMinutes: source.PaidBreakMinutes, + UnpaidBreakMinutes: source.UnpaidBreakMinutes, + AgencySuppliedMeals: source.AgencySuppliedMeals, + AgencySuppliedAccommodation: source.AgencySuppliedAccommodation, + } + ); +}; + +/** Hours per subject for the report summary (the crew boss's check before signing). */ +export const hoursBySubject = (entries: TimeEntry[]) => + entries.reduce>((totals, entry) => { + const id = subjectIdOf(entry); + totals[id] = Math.round(((totals[id] ?? 0) + entryHours(entry)) * 100) / 100; + return totals; + }, {}); + +export const isReportEditable = (report: TimeReport | null) => !!report && report.Status === TimeReportStatus.Draft && report.CanAct; + +export const operationsError = (error: unknown): string => { + const response = (error as { response?: { status?: number; headers?: Record; data?: { type?: string } } })?.response; + const reason = String(response?.headers?.['x-resgrid-reason'] ?? ''); + if (response?.data?.type === 'protected_data_required') return 'locked'; + if (reason.endsWith('_disabled')) return 'disabled'; + if (response?.status === 401 || response?.status === 403 || response?.status === 404) return 'denied'; + if (response?.status === 400) return reason || 'validation'; + if (error instanceof Error && ['denied', 'disabled', 'locked', 'validation'].includes(error.message)) return error.message; + return 'retry'; +}; diff --git a/src/lib/records/__tests__/deployments.test.ts b/src/lib/records/__tests__/deployments.test.ts new file mode 100644 index 00000000..b3f4f9d9 --- /dev/null +++ b/src/lib/records/__tests__/deployments.test.ts @@ -0,0 +1,101 @@ +import { + connectorStateAction, + deploymentStatusAction, + fillStatusAction, + formatWhen, + isConnectorOwned, + reconciliationCounts, + reconciliationFor, + runOutcomeAction, + sortDeployments, + statusKey, + upsertDeployment, +} from '@/lib/records/deployments'; +import { type RecordDeploymentData, type RecordDeploymentReconciliationData } from '@/models/v4/records/deployments'; + +// Presentation helpers for deployments and connectors (RMS plan section 4.1). Identical in all four +// app repositories: what a status looks like must not drift between Responder, Unit, IC and Dispatch. + +const deployment = (orderId: string, modifiedOn: string, extra: Partial = {}): RecordDeploymentData => ({ + OrderId: orderId, + ProfileKey: 'us-wildland', + ProfileVersion: 1, + SourceScheme: 'iroc', + OrderNumber: orderId.toUpperCase(), + IncidentName: 'Bear Creek', + HasArtifact: false, + Status: 'Open', + AllReturned: false, + IsPreview: true, + CreatedOn: modifiedOn, + ModifiedOn: modifiedOn, + RowVersion: 1, + Fills: [], + ...extra, +}); + +const item = (orderId: string, kind: string, requestNumber?: string): RecordDeploymentReconciliationData => ({ OrderId: orderId, OrderNumber: orderId.toUpperCase(), Kind: kind, RequestNumber: requestNumber }); + +describe('deployment presentation helpers', () => { + it('recognises a connector-maintained order from the ownership marker only', () => { + expect(isConnectorOwned(deployment('o1', '2026-09-06', { OwnershipMarker: 'connector' }))).toBe(true); + expect(isConnectorOwned(deployment('o1', '2026-09-06', { OwnershipMarker: 'Connector' }))).toBe(true); + expect(isConnectorOwned(deployment('o1', '2026-09-06', { OwnershipMarker: 'manual', ConnectorId: 'c1' }))).toBe(false); + expect(isConnectorOwned(deployment('o1', '2026-09-06'))).toBe(false); + expect(isConnectorOwned(null)).toBe(false); + }); + + it('maps every order and fill status the server can send onto a badge tone', () => { + expect(['Open', 'Mobilized', 'Released', 'ClosedOut'].map(deploymentStatusAction)).toEqual(['info', 'warning', 'success', 'muted']); + expect(['Requested', 'Accepted', 'Declined', 'Mobilized', 'CheckedIn', 'Assigned', 'Released', 'Demobilized', 'Returned'].map(fillStatusAction)).toEqual([ + 'muted', + 'info', + 'error', + 'warning', + 'warning', + 'info', + 'success', + 'success', + 'success', + ]); + expect(fillStatusAction('SomethingNew')).toBe('muted'); + expect(deploymentStatusAction(undefined)).toBe('muted'); + }); + + it('turns an API status name into its translation suffix', () => { + expect(statusKey('CheckedIn')).toBe('checked_in'); + expect(statusKey('ClosedOut')).toBe('closed_out'); + expect(statusKey('Open')).toBe('open'); + expect(statusKey(null)).toBe(''); + }); + + it('tones run outcomes and connector states from the server flags', () => { + expect(['ok', 'failed', 'rate_limited', 'rejected', 'disabled'].map(runOutcomeAction)).toEqual(['success', 'error', 'warning', 'warning', 'muted']); + expect(connectorStateAction({ IsEnabled: true, IsReadyToRun: true })).toBe('success'); + expect(connectorStateAction({ IsEnabled: true, IsReadyToRun: false })).toBe('warning'); + expect(connectorStateAction({ IsEnabled: false, IsReadyToRun: false })).toBe('muted'); + }); + + it('filters and counts reconciliation items per order without reordering them', () => { + const items = [item('o1', 'source_status_ahead', 'O-1'), item('o2', 'source_closed_local_open'), item('o1', 'local_fill_missing_in_source', 'L-7')]; + expect(reconciliationFor(items, 'o1').map((i) => i.RequestNumber)).toEqual(['O-1', 'L-7']); + expect(reconciliationFor(items, undefined)).toEqual([]); + expect(reconciliationCounts(items)).toEqual({ o1: 2, o2: 1 }); + }); + + it('formats a server timestamp for display and never throws on a bad one', () => { + expect(formatWhen('2026-09-06T12:30:00Z')).toMatch(/2026-09-06/); + expect(formatWhen(undefined)).toBe(''); + expect(formatWhen('not a date')).toBe(''); + }); + + it('sorts newest first and upserts without duplicating an order', () => { + const list = sortDeployments([deployment('o1', '2026-09-01T00:00:00Z'), deployment('o2', '2026-09-03T00:00:00Z'), deployment('o3', '2026-09-02T00:00:00Z')]); + expect(list.map((d) => d.OrderId)).toEqual(['o2', 'o3', 'o1']); + + const updated = upsertDeployment(list, deployment('o1', '2026-09-04T00:00:00Z', { Status: 'Mobilized' })); + expect(updated.map((d) => d.OrderId)).toEqual(['o1', 'o2', 'o3']); + expect(updated).toHaveLength(3); + expect(updated[0].Status).toBe('Mobilized'); + }); +}); diff --git a/src/lib/records/__tests__/fixtures.ts b/src/lib/records/__tests__/fixtures.ts new file mode 100644 index 00000000..cd113f28 --- /dev/null +++ b/src/lib/records/__tests__/fixtures.ts @@ -0,0 +1,149 @@ +import { + type FieldRecordCatalogEntry, + type RecordDefinitionSchema, + RmsFieldClassification, + RmsFieldType, + RmsRuleEffect, + RmsRuleOperator, +} from '@/models/v4/records'; + +/** + * Shared golden fixtures for the Field Records renderer (RMS plan RMS-1D: "shared golden client + * fixtures run against Responder, Unit, IC and Dispatch adapters"). This file is identical in all + * four app repositories on purpose — platform presentation may differ, saved meaning must not. + * + * Every supported control appears once, plus a repeating section whose rules must evaluate per row, + * a restricted field, a protected field, and a control this build cannot render. + */ + +export const GOLDEN_SCHEMA: RecordDefinitionSchema = { + Sections: [ + { + Key: 'summary', + Label: 'Summary', + Fields: [ + { Key: 'title', Label: 'Title', Type: RmsFieldType.ShortText, Required: true, Classification: RmsFieldClassification.Standard }, + { Key: 'narrative', Label: 'Narrative', Type: RmsFieldType.LongText, Classification: RmsFieldClassification.Standard }, + { Key: 'people_count', Label: 'People', Type: RmsFieldType.Integer, Min: 0, Max: 500, Classification: RmsFieldClassification.Standard }, + { Key: 'hours', Label: 'Hours', Type: RmsFieldType.Decimal, Classification: RmsFieldClassification.Standard }, + { Key: 'was_transported', Label: 'Transported', Type: RmsFieldType.Boolean, Classification: RmsFieldClassification.Standard }, + { Key: 'occurred_on', Label: 'Occurred', Type: RmsFieldType.DateTime, Classification: RmsFieldClassification.Standard }, + { Key: 'duration', Label: 'Duration', Type: RmsFieldType.Duration, Classification: RmsFieldClassification.Standard }, + { + Key: 'outcome', + Label: 'Outcome', + Type: RmsFieldType.SingleSelect, + Classification: RmsFieldClassification.Standard, + Options: [ + { Key: 'complete', Label: 'Complete' }, + { Key: 'incomplete', Label: 'Incomplete' }, + { Key: 'retired-option', Label: 'Retired', IsInactive: true }, + ], + }, + { + Key: 'hazards', + Label: 'Hazards', + Type: RmsFieldType.MultiSelect, + Classification: RmsFieldClassification.Standard, + Options: [ + { Key: 'smoke', Label: 'Smoke' }, + { Key: 'electrical', Label: 'Electrical' }, + ], + }, + // Shown only when the outcome is incomplete, so a rule change must move it. + { + Key: 'incomplete_reason', + Label: 'Why incomplete', + Type: RmsFieldType.LongText, + Classification: RmsFieldClassification.Standard, + Rules: [{ Effect: RmsRuleEffect.Show, Condition: { Operator: RmsRuleOperator.Equals, FieldKey: 'outcome', Value: 'incomplete' } }], + }, + // Required only when someone was transported. + { + Key: 'destination', + Label: 'Destination', + Type: RmsFieldType.ShortText, + Classification: RmsFieldClassification.Standard, + Rules: [{ Effect: RmsRuleEffect.Require, Condition: { Operator: RmsRuleOperator.Equals, FieldKey: 'was_transported', Value: 'true' } }], + }, + { Key: 'scene_location', Label: 'Location', Type: RmsFieldType.Address, Classification: RmsFieldClassification.Standard }, + { Key: 'related_call', Label: 'Call', Type: RmsFieldType.CallReference, Classification: RmsFieldClassification.Standard }, + { Key: 'officer', Label: 'Officer', Type: RmsFieldType.Person, Classification: RmsFieldClassification.Standard }, + { Key: 'apparatus', Label: 'Unit', Type: RmsFieldType.Unit, Classification: RmsFieldClassification.Standard }, + { Key: 'station', Label: 'Group', Type: RmsFieldType.Group, Classification: RmsFieldClassification.Standard }, + { Key: 'contact', Label: 'Contact', Type: RmsFieldType.Contact, Classification: RmsFieldClassification.Standard }, + { Key: 'external_ref', Label: 'Reference', Type: RmsFieldType.ExternalReference, ReferenceType: 'permit', Classification: RmsFieldClassification.Standard }, + { Key: 'cost', Label: 'Cost', Type: RmsFieldType.Currency, DefaultCurrency: 'USD', Classification: RmsFieldClassification.Standard }, + { Key: 'water_used', Label: 'Water used', Type: RmsFieldType.Quantity, UnitDimension: 'volume', DefaultUnitCode: 'gal', Classification: RmsFieldClassification.Standard }, + { Key: 'subdivision', Label: 'State', Type: RmsFieldType.CountrySubdivision, Classification: RmsFieldClassification.Standard }, + { Key: 'photo', Label: 'Photo', Type: RmsFieldType.Attachment, Classification: RmsFieldClassification.Standard }, + { Key: 'patient_name', Label: 'Patient', Type: RmsFieldType.ShortText, Classification: RmsFieldClassification.Restricted }, + { Key: 'condition', Label: 'Condition', Type: RmsFieldType.LongText, Classification: RmsFieldClassification.Protected }, + ], + }, + { + Key: 'crew', + Label: 'Crew', + Repeating: true, + MaxRows: 10, + Fields: [ + { Key: 'member', Label: 'Member', Type: RmsFieldType.Person, Required: true, Classification: RmsFieldClassification.Standard }, + { + Key: 'role', + Label: 'Role', + Type: RmsFieldType.SingleSelect, + Classification: RmsFieldClassification.Standard, + Options: [ + { Key: 'driver', Label: 'Driver' }, + { Key: 'officer', Label: 'Officer' }, + ], + }, + // Per-row rule: this cell answers from its own row, never from another row's role. + { + Key: 'driver_note', + Label: 'Driver note', + Type: RmsFieldType.ShortText, + Classification: RmsFieldClassification.Standard, + Rules: [{ Effect: RmsRuleEffect.Show, Condition: { Operator: RmsRuleOperator.Equals, FieldKey: 'role', Value: 'driver' } }], + }, + ], + }, + { + Key: 'signoff', + Label: 'Sign-off', + Fields: [{ Key: 'signature', Label: 'Signature', Type: RmsFieldType.Signature, RequiredToFinalize: true, Classification: RmsFieldClassification.Standard }], + }, + ], +}; + +/** A definition naming a control this build cannot draw; authoring must fail closed. */ +export const UNSUPPORTED_SCHEMA: RecordDefinitionSchema = { + Sections: [ + { + Key: 'main', + Label: 'Main', + Fields: [ + { Key: 'ok', Label: 'Fine', Type: RmsFieldType.ShortText, Classification: RmsFieldClassification.Standard }, + { Key: 'future', Label: 'Future control', Type: 99 as RmsFieldType, Classification: RmsFieldClassification.Standard }, + ], + }, + ], +}; + +export const goldenCatalogEntry = (overrides: Partial = {}): FieldRecordCatalogEntry => ({ + DefinitionKey: 'shift-log', + Version: 3, + Name: 'Shift log', + Category: 'Operations', + Locked: false, + LifecyclePreset: 'QuickEntry', + LaunchContexts: ['none'], + AllowOffline: true, + AllowAttachments: true, + Restricted: false, + RequiresProtectedGrant: false, + SchemaChecksum: 'chk-shift-log', + PrefillVersion: 3, + SupportsPrefill: true, + ...overrides, +}); diff --git a/src/lib/records/__tests__/schema.test.ts b/src/lib/records/__tests__/schema.test.ts new file mode 100644 index 00000000..0045a68e --- /dev/null +++ b/src/lib/records/__tests__/schema.test.ts @@ -0,0 +1,115 @@ +import { + canAuthorOffline, + cellKey, + evaluateRules, + isFieldHidden, + isFieldRequired, + isSupportedField, + rowKeysFor, + toValueList, + toValueMap, + unsupportedFieldKeys, + validate, + type ValueMap, +} from '@/lib/records/schema'; +import { RmsFieldType } from '@/models/v4/records'; + +import { GOLDEN_SCHEMA, goldenCatalogEntry, UNSUPPORTED_SCHEMA } from './fixtures'; + +// Shared conformance suite for the Field Records renderer (RMS plan RMS-1D). This file is identical +// in all four app repositories: every adapter must agree on what is shown, what is required, and +// what is refused, whatever its presentation looks like. + +const set = (values: ValueMap, sectionKey: string, fieldKey: string, value: string, rowKey: string | null = null): ValueMap => ({ + ...values, + [cellKey(sectionKey, fieldKey, rowKey)]: { SectionKey: sectionKey, FieldKey: fieldKey, RowKey: rowKey, Value: value }, +}); + +describe('Field Records schema conformance', () => { + it('renders every control the contract names, and refuses the ones it cannot draw', () => { + for (const section of GOLDEN_SCHEMA.Sections) { + for (const field of section.Fields) { + expect(isSupportedField(field)).toBe(true); + } + } + + expect(unsupportedFieldKeys(GOLDEN_SCHEMA)).toEqual([]); + expect(unsupportedFieldKeys(UNSUPPORTED_SCHEMA)).toEqual(['future']); + }); + + it('shows and requires fields from the values, matching the server rules', () => { + let values: ValueMap = {}; + + let rules = evaluateRules(GOLDEN_SCHEMA, values); + expect(isFieldHidden(rules, 'summary', 'incomplete_reason')).toBe(true); + expect(isFieldRequired(rules, GOLDEN_SCHEMA.Sections[0].Fields.find((f) => f.Key === 'destination')!, 'summary')).toBe(false); + + values = set(values, 'summary', 'outcome', 'incomplete'); + values = set(values, 'summary', 'was_transported', 'true'); + rules = evaluateRules(GOLDEN_SCHEMA, values); + + expect(isFieldHidden(rules, 'summary', 'incomplete_reason')).toBe(false); + expect(isFieldRequired(rules, GOLDEN_SCHEMA.Sections[0].Fields.find((f) => f.Key === 'destination')!, 'summary')).toBe(true); + }); + + it('evaluates a rule inside a repeating section against that row only', () => { + let values: ValueMap = {}; + values = set(values, 'crew', 'role', 'driver', 'row-1'); + values = set(values, 'crew', 'role', 'officer', 'row-2'); + + const rules = evaluateRules(GOLDEN_SCHEMA, values); + + expect(rowKeysFor(values, 'crew')).toEqual(['row-1', 'row-2']); + expect(isFieldHidden(rules, 'crew', 'driver_note', 'row-1')).toBe(false); + expect(isFieldHidden(rules, 'crew', 'driver_note', 'row-2')).toBe(true); + }); + + it('reports required, range and length problems without blocking on hidden fields', () => { + let values: ValueMap = {}; + values = set(values, 'summary', 'people_count', '900'); + + const issues = validate(GOLDEN_SCHEMA, values, false); + + expect(issues.some((issue) => issue.fieldKey === 'title' && issue.code === 'required')).toBe(true); + expect(issues.some((issue) => issue.fieldKey === 'people_count' && issue.code === 'range')).toBe(true); + expect(issues.some((issue) => issue.fieldKey === 'incomplete_reason')).toBe(false); + expect(issues.some((issue) => issue.fieldKey === 'signature')).toBe(false); + }); + + it('only asks for finalize-required fields when finalizing', () => { + let values: ValueMap = set({}, 'summary', 'title', 'Night shift'); + + expect(validate(GOLDEN_SCHEMA, values, false).some((issue) => issue.fieldKey === 'signature')).toBe(false); + expect(validate(GOLDEN_SCHEMA, values, true).some((issue) => issue.fieldKey === 'signature')).toBe(true); + + values = set(values, 'signoff', 'signature', '2026-09-06T00:00:00Z'); + expect(validate(GOLDEN_SCHEMA, values, true).some((issue) => issue.fieldKey === 'signature')).toBe(false); + }); + + it('round-trips stored cells through the value map without inventing rows', () => { + const values = toValueMap([ + { SectionKey: 'summary', FieldKey: 'title', Value: 'Night shift' }, + { SectionKey: 'crew', FieldKey: 'member', RowKey: 'row-1', Ordinal: 0, Value: 'member-1' }, + { SectionKey: 'crew', FieldKey: 'member', RowKey: 'row-2', Ordinal: 1, Value: 'member-2' }, + { SectionKey: 'summary', FieldKey: 'narrative', Value: ' ' }, + ]); + + expect(rowKeysFor(values, 'crew')).toEqual(['row-1', 'row-2']); + const payload = toValueList(values); + expect(payload).toHaveLength(3); + expect(payload.some((value) => value.FieldKey === 'narrative')).toBe(false); + }); + + it('never authors a protected definition offline, whatever its surface asked for', () => { + expect(canAuthorOffline(goldenCatalogEntry())).toBe(true); + expect(canAuthorOffline(goldenCatalogEntry({ AllowOffline: false }))).toBe(false); + expect(canAuthorOffline(goldenCatalogEntry({ AllowOffline: true, RequiresProtectedGrant: true }))).toBe(false); + expect(canAuthorOffline(null)).toBe(false); + }); + + it('treats an attachment and a signature as supported but a future control as not', () => { + expect(isSupportedField({ Key: 'a', Label: 'A', Type: RmsFieldType.Attachment, Classification: 1 })).toBe(true); + expect(isSupportedField({ Key: 'b', Label: 'B', Type: RmsFieldType.Signature, Classification: 1 })).toBe(true); + expect(isSupportedField({ Key: 'c', Label: 'C', Type: 99 as RmsFieldType, Classification: 1 })).toBe(false); + }); +}); diff --git a/src/lib/records/__tests__/uploads.test.ts b/src/lib/records/__tests__/uploads.test.ts new file mode 100644 index 00000000..f12e2637 --- /dev/null +++ b/src/lib/records/__tests__/uploads.test.ts @@ -0,0 +1,172 @@ +import { runUpload } from '@/lib/records/uploads'; + +// Shared conformance suite for resumable attachment upload (RMS plan RMS-1D). Identical in all four +// app repositories: the server owns the session, so every adapter must resume from the server's own +// byte count rather than its own, and must surface a failure instead of retrying it silently. + +jest.mock('@/api/records/record-uploads', () => ({ + beginRecordUpload: jest.fn(), + uploadRecordChunk: jest.fn(), + getRecordUpload: jest.fn(), + completeRecordUpload: jest.fn(), + abortRecordUpload: jest.fn(), + getRecordAttachments: jest.fn(), + removeRecordAttachment: jest.fn(), +})); + +jest.mock('expo-file-system/legacy', () => ({ + EncodingType: { Base64: 'base64' }, + getInfoAsync: jest.fn(), + readAsStringAsync: jest.fn(), +})); + +jest.mock('expo-crypto', () => ({ + CryptoDigestAlgorithm: { SHA256: 'SHA-256' }, + CryptoEncoding: { HEX: 'hex' }, + digestStringAsync: jest.fn(), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const api = jest.requireMock('@/api/records/record-uploads'); +const fs = jest.requireMock('expo-file-system/legacy'); + +// 9 bytes encodes to 12 base64 characters, so a 3-byte chunk size slices cleanly. +const NINE_BYTES_BASE64 = 'AAAAAAAAAAAA'; + +const pending = (overrides: Record = {}) => ({ + localId: 'upload-1', + recordId: 'r1', + uploadId: null, + fileUri: 'file:///photo.jpg', + fileName: 'photo.jpg', + contentType: 'image/jpeg', + byteSize: 9, + sha256: 'abc', + sentBytes: 0, + classification: 1, + createdOn: '2026-09-06T00:00:00Z', + ...overrides, +}); + +const session = (receivedBytes: number, chunkSize = 3) => ({ + Data: { + UploadId: 'session-1', + RecordId: 'r1', + FileName: 'photo.jpg', + ContentType: 'image/jpeg', + DeclaredSize: 9, + ReceivedBytes: receivedBytes, + ChunkSize: chunkSize, + ChunkCount: 3, + State: 1, + ExpiresOn: '2026-09-07T00:00:00Z', + }, +}); + +describe('Record attachment uploads', () => { + beforeEach(() => { + jest.clearAllMocks(); + fs.getInfoAsync.mockResolvedValue({ exists: true, size: 9 }); + fs.readAsStringAsync.mockResolvedValue(NINE_BYTES_BASE64); + }); + + it('sends the file in chunks and completes it', async () => { + api.beginRecordUpload.mockResolvedValue(session(0)); + api.uploadRecordChunk.mockResolvedValueOnce(session(3)).mockResolvedValueOnce(session(6)).mockResolvedValueOnce(session(9)); + api.completeRecordUpload.mockResolvedValue({ Data: { AttachmentId: 'a1', FileName: 'photo.jpg', MetadataStripped: true, MediaLocationRetained: false } }); + const progress: number[] = []; + + const outcome = await runUpload(pending() as never, { onProgress: ({ sentBytes }) => progress.push(sentBytes) }); + + expect(outcome.ok).toBe(true); + expect(outcome.attachment?.AttachmentId).toBe('a1'); + expect(api.uploadRecordChunk).toHaveBeenCalledTimes(3); + const offsets = api.uploadRecordChunk.mock.calls.map((call: unknown[]) => (call[0] as { Offset: number }).Offset); + expect(offsets).toEqual([0, 3, 6]); + expect(progress).toEqual([0, 3, 6, 9]); + }); + + it('sends the whole padded group for a final chunk that is not a multiple of 3 bytes', async () => { + // 10 bytes: three full 3-byte chunks (4 base64 chars each) and a trailing single byte, which + // base64 pads to a full 4-character group ("AA==") that must reach the server intact. + const tenBytesBase64 = 'AAAAAAAAAAAAAA=='; + fs.getInfoAsync.mockResolvedValue({ exists: true, size: 10 }); + fs.readAsStringAsync.mockResolvedValue(tenBytesBase64); + api.beginRecordUpload.mockResolvedValue({ Data: { ...session(0).Data, DeclaredSize: 10, ChunkCount: 4 } }); + api.uploadRecordChunk.mockResolvedValueOnce(session(3)).mockResolvedValueOnce(session(6)).mockResolvedValueOnce(session(9)).mockResolvedValueOnce(session(10)); + api.completeRecordUpload.mockResolvedValue({ Data: { AttachmentId: 'a4' } }); + + const outcome = await runUpload(pending({ byteSize: 10 }) as never); + + expect(outcome.ok).toBe(true); + const chunks: { Offset: number; Data: string }[] = api.uploadRecordChunk.mock.calls.map((call: unknown[]) => call[0] as { Offset: number; Data: string }); + expect(chunks.map((chunk) => chunk.Offset)).toEqual([0, 3, 6, 9]); + expect(chunks.map((chunk) => chunk.Data)).toEqual(['AAAA', 'AAAA', 'AAAA', 'AA==']); + expect(chunks.map((chunk) => chunk.Data).join('')).toBe(tenBytesBase64); + }); + + it('resumes from the count the server reports, not the one the device remembers', async () => { + api.getRecordUpload.mockResolvedValue(session(6)); + api.uploadRecordChunk.mockResolvedValue(session(9)); + api.completeRecordUpload.mockResolvedValue({ Data: { AttachmentId: 'a2' } }); + + const outcome = await runUpload(pending({ uploadId: 'session-1', sentBytes: 0 }) as never); + + expect(outcome.ok).toBe(true); + expect(api.beginRecordUpload).not.toHaveBeenCalled(); + expect(api.uploadRecordChunk).toHaveBeenCalledTimes(1); + expect(api.uploadRecordChunk.mock.calls[0][0].Offset).toBe(6); + }); + + it('opens a new session when the old one is gone or closed', async () => { + api.getRecordUpload.mockResolvedValue({ Data: { ...session(9).Data, State: 4 } }); + api.beginRecordUpload.mockResolvedValue(session(0)); + api.uploadRecordChunk.mockResolvedValueOnce(session(3)).mockResolvedValueOnce(session(6)).mockResolvedValueOnce(session(9)); + api.completeRecordUpload.mockResolvedValue({ Data: { AttachmentId: 'a3' } }); + + const outcome = await runUpload(pending({ uploadId: 'expired' }) as never); + + expect(outcome.ok).toBe(true); + expect(api.beginRecordUpload).toHaveBeenCalledTimes(1); + }); + + it('reports a missing file as unrecoverable rather than trying to send it', async () => { + fs.getInfoAsync.mockResolvedValue({ exists: false }); + + const outcome = await runUpload(pending() as never); + + expect(outcome).toMatchObject({ ok: false, code: 'file_missing' }); + expect(api.beginRecordUpload).not.toHaveBeenCalled(); + expect(api.uploadRecordChunk).not.toHaveBeenCalled(); + }); + + it('surfaces a rejected upload with the server code and keeps how far it got', async () => { + api.beginRecordUpload.mockResolvedValue(session(0)); + api.uploadRecordChunk.mockRejectedValue({ response: { status: 422, data: { type: 'upload_rejected', title: 'That file type is not accepted.' } } }); + + const outcome = await runUpload(pending() as never); + + expect(outcome.ok).toBe(false); + expect(outcome.code).toBe('upload_rejected'); + expect(outcome.message).toBe('That file type is not accepted.'); + }); + + it('stops between chunks when the person cancels', async () => { + api.beginRecordUpload.mockResolvedValue(session(0)); + api.uploadRecordChunk.mockResolvedValue(session(3)); + let calls = 0; + + const outcome = await runUpload(pending() as never, { + shouldCancel: () => { + calls += 1; + return calls > 1; + }, + }); + + expect(outcome).toMatchObject({ ok: false, code: 'cancelled', sentBytes: 3 }); + expect(api.completeRecordUpload).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/records/deployments.ts b/src/lib/records/deployments.ts new file mode 100644 index 00000000..8ca5f332 --- /dev/null +++ b/src/lib/records/deployments.ts @@ -0,0 +1,105 @@ +import { formatDateForDisplay, parseDateISOString } from '@/lib/utils'; +import { type RecordDeploymentConnectorData, type RecordDeploymentData, type RecordDeploymentReconciliationData, RmsConnectorRunOutcomes, RmsExternalOrderOwnership } from '@/models/v4/records/deployments'; + +// Presentation helpers for deployments and connectors (RMS plan section 4.1). Pure functions over the +// server's data: nothing here decides what a person may see or do, only how what came back is shown. + +export type BadgeAction = 'muted' | 'info' | 'warning' | 'success' | 'error'; + +/** A server timestamp for display, or an empty string when it is missing or unparsable. */ +export const formatWhen = (timestamp: string | null | undefined, format = 'yyyy-MM-dd HH:mm'): string => { + if (!timestamp) { + return ''; + } + try { + const parsed = parseDateISOString(timestamp); + // parseDateISOString answers null for an unparsable value, and older utils returned an Invalid + // Date instead of throwing; either way the row shows nothing rather than NaN. + return parsed && !Number.isNaN(parsed.getTime()) ? formatDateForDisplay(parsed, format) : ''; + } catch { + return ''; + } +}; + +/** True when an external ordering-system connector maintains this order's snapshots. */ +export const isConnectorOwned = (deployment: Pick | null | undefined): boolean => (deployment?.OwnershipMarker ?? '').toLowerCase() === RmsExternalOrderOwnership.Connector; + +export const deploymentStatusAction = (status: string | null | undefined): BadgeAction => { + switch ((status ?? '').toLowerCase()) { + case 'open': + return 'info'; + case 'mobilized': + return 'warning'; + case 'released': + return 'success'; + case 'closedout': + return 'muted'; + default: + return 'muted'; + } +}; + +export const fillStatusAction = (status: string | null | undefined): BadgeAction => { + switch ((status ?? '').toLowerCase()) { + case 'requested': + return 'muted'; + case 'accepted': + case 'assigned': + return 'info'; + case 'mobilized': + case 'checkedin': + return 'warning'; + case 'released': + case 'demobilized': + case 'returned': + return 'success'; + case 'declined': + return 'error'; + default: + return 'muted'; + } +}; + +/** Translation suffix for a status name as the API spells it (CheckedIn -> checked_in). */ +export const statusKey = (status: string | null | undefined): string => (status ?? '').replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(); + +export const runOutcomeAction = (outcome: string | null | undefined): BadgeAction => { + switch ((outcome ?? '').toLowerCase()) { + case RmsConnectorRunOutcomes.Ok: + return 'success'; + case RmsConnectorRunOutcomes.Failed: + return 'error'; + case RmsConnectorRunOutcomes.RateLimited: + case RmsConnectorRunOutcomes.Rejected: + return 'warning'; + default: + return 'muted'; + } +}; + +/** A connector is shown as ready, enabled-but-blocked, or off; the server's own flags decide. */ +export const connectorStateAction = (connector: Pick): BadgeAction => { + if (connector.IsReadyToRun) { + return 'success'; + } + return connector.IsEnabled ? 'warning' : 'muted'; +}; + +/** Reconciliation items for one order, in the order the server listed them. */ +export const reconciliationFor = (items: RecordDeploymentReconciliationData[], orderId: string | null | undefined): RecordDeploymentReconciliationData[] => + orderId ? items.filter((item) => item.OrderId === orderId) : []; + +/** Open items per order id, for the count shown beside a deployment. */ +export const reconciliationCounts = (items: RecordDeploymentReconciliationData[]): Record => + items.reduce>((counts, item) => { + counts[item.OrderId] = (counts[item.OrderId] ?? 0) + 1; + return counts; + }, {}); + +/** Newest first by modification, so the deployment that just changed is at the top. */ +export const sortDeployments = (deployments: RecordDeploymentData[]): RecordDeploymentData[] => + [...deployments].sort((a, b) => (b.ModifiedOn ?? '').localeCompare(a.ModifiedOn ?? '') || a.OrderNumber.localeCompare(b.OrderNumber)); + +/** Merges one freshly fetched deployment into a list without duplicating it. */ +export const upsertDeployment = (deployments: RecordDeploymentData[], deployment: RecordDeploymentData): RecordDeploymentData[] => + sortDeployments([deployment, ...deployments.filter((existing) => existing.OrderId !== deployment.OrderId)]); diff --git a/src/lib/records/schema.ts b/src/lib/records/schema.ts new file mode 100644 index 00000000..20f3c190 --- /dev/null +++ b/src/lib/records/schema.ts @@ -0,0 +1,335 @@ +import { + type FieldRecordCatalogEntry, + type RecordConditionSchema, + type RecordDefinitionSchema, + type RecordFieldSchema, + RecordsClientCapabilities, + type RecordSectionSchema, + type RecordValueCellData, + type RecordValueInput, + RmsFieldClassification, + RmsFieldType, + RmsRuleEffect, + RmsRuleOperator, +} from '@/models/v4/records'; + +// Client-side schema helpers for the Field Records renderer (RMS plan RMS-1D). Rule evaluation here +// must produce the same answer as RecordTypedValuesService.EvaluateRules on the server: a rule inside +// a repeating section evaluates per row against that row's own cells, and the server revalidates +// everything on save regardless. This is presentation, never authorization. + +/** The controls this renderer can draw. A definition naming anything else fails closed. */ +export const SUPPORTED_FIELD_TYPES: ReadonlySet = new Set([ + RmsFieldType.ShortText, + RmsFieldType.LongText, + RmsFieldType.Integer, + RmsFieldType.Decimal, + RmsFieldType.Boolean, + RmsFieldType.Date, + RmsFieldType.DateTime, + RmsFieldType.Duration, + RmsFieldType.SingleSelect, + RmsFieldType.MultiSelect, + RmsFieldType.Address, + RmsFieldType.Person, + RmsFieldType.Unit, + RmsFieldType.Group, + RmsFieldType.Contact, + RmsFieldType.Signature, + RmsFieldType.ExternalReference, + RmsFieldType.Currency, + RmsFieldType.Quantity, + RmsFieldType.CountrySubdivision, + RmsFieldType.CallReference, + RmsFieldType.Attachment, +]); + +/** The capability this build reports to the server. Raise only when every control of the tier renders. */ +export const CLIENT_CAPABILITY: string = RecordsClientCapabilities.Packs; + +export const isSupportedField = (field: RecordFieldSchema): boolean => SUPPORTED_FIELD_TYPES.has(field.Type); + +/** Field keys the renderer cannot draw; a definition with any is refused for authoring. */ +export const unsupportedFieldKeys = (schema: RecordDefinitionSchema | null | undefined): string[] => + (schema?.Sections ?? []).flatMap((section) => (section.Fields ?? []).filter((field) => !isSupportedField(field)).map((field) => field.Key)); + +/** A value keyed by section, field and (for repeating sections) row. */ +export type ValueMap = Record; + +export const cellKey = (sectionKey: string, fieldKey: string, rowKey?: string | null): string => `${sectionKey}::${rowKey ?? ''}::${fieldKey}`; + +export const emptyValue = (sectionKey: string, fieldKey: string, rowKey?: string | null): RecordValueInput => ({ + SectionKey: sectionKey, + FieldKey: fieldKey, + RowKey: rowKey ?? null, +}); + +export const toValueMap = (cells: RecordValueCellData[] | null | undefined): ValueMap => { + const map: ValueMap = {}; + for (const cell of cells ?? []) { + if (!cell?.FieldKey || !cell?.SectionKey) { + continue; + } + map[cellKey(cell.SectionKey, cell.FieldKey, cell.RowKey)] = { + SectionKey: cell.SectionKey, + FieldKey: cell.FieldKey, + RowKey: cell.RowKey ?? null, + Ordinal: cell.Ordinal ?? 0, + Value: cell.Value ?? null, + Values: cell.Values ?? null, + ReferenceType: cell.ReferenceType ?? null, + ReferenceId: cell.ReferenceId ?? null, + UnitCode: cell.UnitCode ?? null, + }; + } + return map; +}; + +export const toValueList = (values: ValueMap): RecordValueInput[] => Object.values(values).filter((value) => !isBlank(value)); + +export const isBlank = (value: RecordValueInput | undefined | null): boolean => { + if (!value) { + return true; + } + if (value.Values && value.Values.length > 0) { + return false; + } + if (value.ReferenceId && value.ReferenceId.length > 0) { + return false; + } + return value.Value === null || value.Value === undefined || value.Value.trim().length === 0; +}; + +/** Row keys present for a repeating section, in stored order. */ +export const rowKeysFor = (values: ValueMap, sectionKey: string): string[] => { + const seen = new Map(); + for (const value of Object.values(values)) { + if (value.SectionKey !== sectionKey || !value.RowKey) { + continue; + } + if (!seen.has(value.RowKey)) { + seen.set(value.RowKey, value.Ordinal ?? seen.size); + } + } + return [...seen.entries()].sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0])).map(([key]) => key); +}; + +const scalarText = (value: RecordValueInput | undefined): string => { + if (!value) { + return ''; + } + if (value.Values && value.Values.length > 0) { + return value.Values.join(','); + } + return value.ReferenceId ?? value.Value ?? ''; +}; + +const resolve = (values: ValueMap, schema: RecordDefinitionSchema, fieldKey: string, sectionKey: string, rowKey?: string | null): RecordValueInput | undefined => { + // Inside a repeating row the row's own cell wins; otherwise fall back to the scalar value + // anywhere in the schema, matching the server's Evaluate(condition, schema, values, depth, row). + if (rowKey) { + const own = values[cellKey(sectionKey, fieldKey, rowKey)]; + if (own) { + return own; + } + } + for (const section of schema.Sections ?? []) { + if (section.Repeating) { + continue; + } + const candidate = values[cellKey(section.Key, fieldKey, null)]; + if (candidate) { + return candidate; + } + } + return undefined; +}; + +const MAX_RULE_DEPTH = 5; + +export const evaluateCondition = (condition: RecordConditionSchema | null | undefined, schema: RecordDefinitionSchema, values: ValueMap, sectionKey: string, rowKey?: string | null, depth = 0): boolean => { + if (!condition || depth > MAX_RULE_DEPTH) { + return true; + } + + if (condition.Operator === RmsRuleOperator.And || condition.Operator === RmsRuleOperator.Or) { + const children = condition.Conditions ?? []; + if (children.length === 0) { + return true; + } + return condition.Operator === RmsRuleOperator.And + ? children.every((child) => evaluateCondition(child, schema, values, sectionKey, rowKey, depth + 1)) + : children.some((child) => evaluateCondition(child, schema, values, sectionKey, rowKey, depth + 1)); + } + + if (!condition.FieldKey) { + return true; + } + + const value = resolve(values, schema, condition.FieldKey, sectionKey, rowKey); + const text = scalarText(value); + const blank = isBlank(value); + + switch (condition.Operator) { + case RmsRuleOperator.Equals: + return text.localeCompare(condition.Value ?? '', undefined, { sensitivity: 'accent' }) === 0; + case RmsRuleOperator.NotEquals: + return text.localeCompare(condition.Value ?? '', undefined, { sensitivity: 'accent' }) !== 0; + case RmsRuleOperator.InSet: + return (condition.Values ?? []).some((candidate) => candidate.localeCompare(text, undefined, { sensitivity: 'accent' }) === 0); + case RmsRuleOperator.NotInSet: + return !(condition.Values ?? []).some((candidate) => candidate.localeCompare(text, undefined, { sensitivity: 'accent' }) === 0); + case RmsRuleOperator.IsEmpty: + return blank; + case RmsRuleOperator.IsNotEmpty: + return !blank; + case RmsRuleOperator.InRange: { + const numeric = Number.parseFloat(text); + if (Number.isNaN(numeric)) { + return false; + } + if (condition.Min !== null && condition.Min !== undefined && numeric < condition.Min) { + return false; + } + if (condition.Max !== null && condition.Max !== undefined && numeric > condition.Max) { + return false; + } + return true; + } + default: + return true; + } +}; + +export interface RuleState { + hiddenFieldKeys: Set; + requiredFieldKeys: Set; + hiddenSectionKeys: Set; +} + +const evaluateFieldRules = (field: RecordFieldSchema, schema: RecordDefinitionSchema, values: ValueMap, sectionKey: string, rowKey: string | null, state: RuleState) => { + for (const rule of field.Rules ?? []) { + const met = evaluateCondition(rule.Condition, schema, values, sectionKey, rowKey); + const key = cellKey(sectionKey, field.Key, rowKey); + if (rule.Effect === RmsRuleEffect.Show && !met) { + state.hiddenFieldKeys.add(key); + } + if (rule.Effect === RmsRuleEffect.Require && met) { + state.requiredFieldKeys.add(key); + } + } +}; + +/** + * Which fields are shown and which are required right now. Repeating sections evaluate per row, so a + * rule on a row's field answers from that row's cells, exactly as the server does. + */ +export const evaluateRules = (schema: RecordDefinitionSchema | null | undefined, values: ValueMap): RuleState => { + const state: RuleState = { hiddenFieldKeys: new Set(), requiredFieldKeys: new Set(), hiddenSectionKeys: new Set() }; + if (!schema) { + return state; + } + + for (const section of schema.Sections ?? []) { + for (const rule of section.Rules ?? []) { + if (rule.Effect === RmsRuleEffect.Show && !evaluateCondition(rule.Condition, schema, values, section.Key, null)) { + state.hiddenSectionKeys.add(section.Key); + } + } + if (state.hiddenSectionKeys.has(section.Key)) { + continue; + } + + if (section.Repeating) { + for (const rowKey of rowKeysFor(values, section.Key)) { + for (const field of section.Fields ?? []) { + evaluateFieldRules(field, schema, values, section.Key, rowKey, state); + } + } + continue; + } + + for (const field of section.Fields ?? []) { + evaluateFieldRules(field, schema, values, section.Key, null, state); + } + } + + return state; +}; + +export const isFieldHidden = (state: RuleState, sectionKey: string, fieldKey: string, rowKey?: string | null): boolean => state.hiddenFieldKeys.has(cellKey(sectionKey, fieldKey, rowKey ?? null)); + +export const isFieldRequired = (state: RuleState, field: RecordFieldSchema, sectionKey: string, rowKey?: string | null, forFinalize = false): boolean => { + if (field.Required) { + return true; + } + if (forFinalize && field.RequiredToFinalize) { + return true; + } + return state.requiredFieldKeys.has(cellKey(sectionKey, field.Key, rowKey ?? null)); +}; + +export interface ValidationIssue { + sectionKey: string; + fieldKey: string; + rowKey: string | null; + label: string; + code: 'required' | 'range' | 'length'; +} + +/** Local validation for immediate feedback. The server validates again and its answer wins. */ +export const validate = (schema: RecordDefinitionSchema | null | undefined, values: ValueMap, forFinalize: boolean): ValidationIssue[] => { + const issues: ValidationIssue[] = []; + if (!schema) { + return issues; + } + const state = evaluateRules(schema, values); + + const checkField = (section: RecordSectionSchema, field: RecordFieldSchema, rowKey: string | null) => { + if (isFieldHidden(state, section.Key, field.Key, rowKey)) { + return; + } + const value = values[cellKey(section.Key, field.Key, rowKey)]; + if (isBlank(value)) { + if (isFieldRequired(state, field, section.Key, rowKey, forFinalize)) { + issues.push({ sectionKey: section.Key, fieldKey: field.Key, rowKey, label: field.Label, code: 'required' }); + } + return; + } + if (field.Type === RmsFieldType.Integer || field.Type === RmsFieldType.Decimal || field.Type === RmsFieldType.Currency || field.Type === RmsFieldType.Quantity) { + const numeric = Number.parseFloat(value?.Value ?? ''); + if (Number.isNaN(numeric) || (field.Min !== null && field.Min !== undefined && numeric < field.Min) || (field.Max !== null && field.Max !== undefined && numeric > field.Max)) { + issues.push({ sectionKey: section.Key, fieldKey: field.Key, rowKey, label: field.Label, code: 'range' }); + } + } + if (field.MaxLength && (value?.Value?.length ?? 0) > field.MaxLength) { + issues.push({ sectionKey: section.Key, fieldKey: field.Key, rowKey, label: field.Label, code: 'length' }); + } + }; + + for (const section of schema.Sections ?? []) { + if (state.hiddenSectionKeys.has(section.Key)) { + continue; + } + if (section.Repeating) { + for (const rowKey of rowKeysFor(values, section.Key)) { + for (const field of section.Fields ?? []) { + checkField(section, field, rowKey); + } + } + continue; + } + for (const field of section.Fields ?? []) { + checkField(section, field, null); + } + } + + return issues; +}; + +/** A protected field is authored only with a live grant; it is never written to an offline draft. */ +export const isProtected = (field: RecordFieldSchema): boolean => field.Classification === RmsFieldClassification.Protected; +export const isRestricted = (field: RecordFieldSchema): boolean => field.Classification !== RmsFieldClassification.Standard; + +/** True when this catalog entry may be authored without a connection. */ +export const canAuthorOffline = (entry: FieldRecordCatalogEntry | null | undefined): boolean => !!entry?.AllowOffline && !entry.RequiresProtectedGrant; diff --git a/src/lib/records/uploads.ts b/src/lib/records/uploads.ts new file mode 100644 index 00000000..c5cc8f38 --- /dev/null +++ b/src/lib/records/uploads.ts @@ -0,0 +1,191 @@ +import * as Crypto from 'expo-crypto'; +import * as FileSystem from 'expo-file-system/legacy'; + +import { abortRecordUpload, beginRecordUpload, completeRecordUpload, getRecordUpload, type RecordAttachmentData, type RecordUploadData, uploadRecordChunk } from '@/api/records/record-uploads'; +import { logger } from '@/lib/logging'; + +// Resumable attachment upload for the field apps (RMS plan RMS-1D). The session lives on the server: +// it declares the chunk size and remembers how many bytes it holds, so an upload interrupted by a +// dead battery, a lost signal or a killed app resumes from the server's own count rather than +// starting again. Nothing is retried silently — a failure comes back for a person to act on. + +/** RecordUploadSessionState on the server. */ +export const RecordUploadState = { + Open: 1, + Completed: 2, + Aborted: 3, + Expired: 4, +} as const; + +export interface PendingUpload { + /** Client id; also the key this upload is persisted under so it survives a restart. */ + localId: string; + recordId: string; + uploadId: string | null; + fileUri: string; + fileName: string; + contentType: string; + byteSize: number; + sha256: string; + sentBytes: number; + description?: string | null; + classification: number; + createdOn: string; + lastError?: string | null; + /** Set when the file itself is gone; retrying cannot help and the person is told. */ + isUnrecoverable?: boolean; +} + +export interface UploadProgress { + sentBytes: number; + totalBytes: number; +} + +export interface UploadOutcome { + ok: boolean; + attachment?: RecordAttachmentData; + /** A coded reason: the server's problem type, or one of file_missing / cancelled / failed. */ + code?: string; + message?: string; + /** How far the upload got, so a resume can pick up from here. */ + sentBytes?: number; + uploadId?: string | null; +} + +const problemCode = (error: unknown): string | undefined => { + const response = (error as { response?: { data?: { type?: string }; status?: number } })?.response; + if (!response) { + return undefined; + } + return response.data?.type ?? (response.status ? `http_${response.status}` : undefined); +}; + +const problemMessage = (error: unknown): string => { + const response = (error as { response?: { data?: { title?: string } } })?.response; + return response?.data?.title ?? (error instanceof Error ? error.message : 'Upload failed'); +}; + +/** SHA-256 of the file, hex lower-case, computed without holding the whole file as a string twice. */ +export const hashFile = async (fileUri: string): Promise => { + const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 }); + return (await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, base64, { encoding: Crypto.CryptoEncoding.HEX })).toLowerCase(); +}; + +export const fileSize = async (fileUri: string): Promise => { + const info = await FileSystem.getInfoAsync(fileUri); + return info.exists && typeof info.size === 'number' ? info.size : 0; +}; + +/** + * The server hashes the assembled bytes, so the hash we declare has to be of the same bytes. Reading + * base64 once and slicing it keeps the chunk boundaries aligned: base64 encodes 3 bytes as 4 chars, + * so a chunk size that is a multiple of 3 slices cleanly without re-encoding anything. + */ +const chunkOf = (base64: string, offsetBytes: number, chunkBytes: number): string => { + const start = (offsetBytes / 3) * 4; + // Only the final chunk can be short of a multiple of 3; its trailing 1 or 2 bytes still occupy a + // full padded 4-character group, and slice() would otherwise truncate the fraction and drop them. + const length = Math.ceil(chunkBytes / 3) * 4; + return base64.slice(start, start + length); +}; + +const alignChunkSize = (chunkSize: number): number => { + const safe = Math.max(3, Math.min(chunkSize || 0, 3 * 1024 * 1024)); + return safe - (safe % 3); +}; + +export interface UploadOptions { + onProgress?: (progress: UploadProgress) => void; + /** Checked between chunks so a person can stop an upload without killing the screen. */ + shouldCancel?: () => boolean; + signal?: AbortSignal; +} + +/** + * Sends a file to an existing or new session and completes it. Resuming is the normal path, not a + * special case: the caller passes the pending upload back in and the server says where to continue. + */ +export const runUpload = async (pending: PendingUpload, options: UploadOptions = {}): Promise => { + try { + const info = await FileSystem.getInfoAsync(pending.fileUri); + if (!info.exists) { + // The picker's cache was cleared, or the person deleted the photo. Retrying cannot fix that. + return { ok: false, code: 'file_missing', message: 'The file is no longer on this device.', uploadId: pending.uploadId }; + } + + let session: RecordUploadData | null = null; + if (pending.uploadId) { + try { + session = (await getRecordUpload(pending.uploadId, options.signal))?.Data ?? null; + } catch (error) { + // A session the server has forgotten is not an error worth surfacing; a new one is opened. + logger.info({ message: 'Record upload session could not be resumed; starting a new one', context: { uploadId: pending.uploadId } }); + } + if (session && session.State !== RecordUploadState.Open) { + session = null; + } + } + + if (!session) { + session = + ( + await beginRecordUpload( + { + RecordId: pending.recordId, + FileName: pending.fileName, + ContentType: pending.contentType, + ByteSize: pending.byteSize, + Sha256: pending.sha256, + }, + options.signal + ) + )?.Data ?? null; + } + + if (!session?.UploadId) { + return { ok: false, code: 'no_session', message: 'The server did not open an upload.', uploadId: null }; + } + + const chunkSize = alignChunkSize(session.ChunkSize); + const base64 = await FileSystem.readAsStringAsync(pending.fileUri, { encoding: FileSystem.EncodingType.Base64 }); + // The server's count is authoritative: it is the only thing that knows what actually arrived. + let sent = session.ReceivedBytes ?? 0; + options.onProgress?.({ sentBytes: sent, totalBytes: pending.byteSize }); + + while (sent < pending.byteSize) { + if (options.shouldCancel?.()) { + return { ok: false, code: 'cancelled', sentBytes: sent, uploadId: session.UploadId }; + } + const size = Math.min(chunkSize, pending.byteSize - sent); + const data = chunkOf(base64, sent, size); + const updated = (await uploadRecordChunk({ UploadId: session.UploadId, Offset: sent, Data: data }, options.signal))?.Data; + if (!updated) { + return { ok: false, code: 'chunk_failed', sentBytes: sent, uploadId: session.UploadId }; + } + // Trust the server's new count rather than adding locally, so a partially accepted chunk + // cannot leave the client and the server disagreeing about where the file is. + sent = updated.ReceivedBytes; + options.onProgress?.({ sentBytes: sent, totalBytes: pending.byteSize }); + } + + const attachment = (await completeRecordUpload({ UploadId: session.UploadId, Description: pending.description ?? null, Classification: pending.classification }, options.signal))?.Data; + + return attachment?.AttachmentId ? { ok: true, attachment, sentBytes: sent, uploadId: session.UploadId } : { ok: false, code: 'complete_failed', sentBytes: sent, uploadId: session.UploadId }; + } catch (error) { + const code = problemCode(error); + logger.error({ message: 'Record attachment upload failed', context: { error, recordId: pending.recordId, code } }); + return { ok: false, code: code ?? 'failed', message: problemMessage(error), uploadId: pending.uploadId }; + } +}; + +/** Gives the session back to the server so its chunks are not left occupying storage. */ +export const cancelUpload = async (uploadId: string | null): Promise => { + if (!uploadId) { + return; + } + try { + await abortRecordUpload(uploadId); + } catch (error) { + logger.info({ message: 'Record upload could not be aborted; it will expire on its own', context: { uploadId } }); + } +}; diff --git a/src/lib/status-destination.ts b/src/lib/status-destination.ts new file mode 100644 index 00000000..ed365cda --- /dev/null +++ b/src/lib/status-destination.ts @@ -0,0 +1,71 @@ +import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { DestinationEntityTypes } from '@/models/v4/destinations/destinationEntityTypes'; +import { type UnitStatusResultData } from '@/models/v4/unitStatus/unitStatusResultData'; + +type UnitStatusDestination = Pick; + +/** + * The call id the unit's latest server-side status points at, or null when that status has no + * destination or its destination is a station/POI. + * + * Dispatching a unit writes a "Responding" status for it with the dispatched call as destination, + * so this is how a crew that never set an active call still gets that call as the default. + * Legacy rows carry no destination type (null/0); the server treats an untyped RespondingTo as a + * call, so those count as a call here — callers still require the id to match an open call. + */ +export const getUnitStatusCallDestinationId = (unitStatus: UnitStatusDestination | null | undefined, unitId?: string | null): string | null => { + if (!unitStatus) { + return null; + } + + // activeUnitStatus is refreshed asynchronously after a unit switch — never borrow another + // unit's destination while it catches up. + if (unitId && unitStatus.UnitId && String(unitStatus.UnitId) !== String(unitId)) { + return null; + } + + const destinationId = unitStatus.DestinationId == null ? '' : String(unitStatus.DestinationId).trim(); + if (destinationId === '' || destinationId === '0') { + return null; + } + + const rawType = unitStatus.DestinationType; + const destinationType = rawType == null || rawType === '' ? DestinationEntityTypes.None : Number(rawType); + + if (destinationType !== DestinationEntityTypes.Call && destinationType !== DestinationEntityTypes.None) { + return null; + } + + return destinationId; +}; + +/** + * The call a status should default to: the crew's active call when it is still open, otherwise + * the open call the unit's latest status points at. Returns null when neither is in the list of + * open calls — a closed call must never be offered, it would be rejected by the server. + */ +export const resolveDefaultStatusCall = ({ + availableCalls, + activeCallId, + unitStatus, + unitId, +}: { + availableCalls: CallResultData[]; + activeCallId: string | null | undefined; + unitStatus: UnitStatusDestination | null | undefined; + unitId?: string | null; +}): CallResultData | null => { + if (activeCallId) { + const activeCall = availableCalls.find((call) => call.CallId === activeCallId); + if (activeCall) { + return activeCall; + } + } + + const unitStatusCallId = getUnitStatusCallDestinationId(unitStatus, unitId); + if (!unitStatusCallId) { + return null; + } + + return availableCalls.find((call) => String(call.CallId) === unitStatusCallId) ?? null; +}; diff --git a/src/models/offline-queue/queued-event.ts b/src/models/offline-queue/queued-event.ts index 9ad90afe..5c5a6b58 100644 --- a/src/models/offline-queue/queued-event.ts +++ b/src/models/offline-queue/queued-event.ts @@ -1,4 +1,5 @@ export enum QueuedEventType { + CHECKLIST_COMPLETION = 'checklist_completion', UNIT_STATUS = 'unit_status', LOCATION_UPDATE = 'location_update', CALL_IMAGE_UPLOAD = 'call_image_upload', diff --git a/src/models/v4/calls/callResultData.ts b/src/models/v4/calls/callResultData.ts index 9fb0efc5..3c7b1eab 100644 --- a/src/models/v4/calls/callResultData.ts +++ b/src/models/v4/calls/callResultData.ts @@ -1,3 +1,5 @@ +import { type CallContactResultData } from './callSiteInfoResult'; + export class CallResultData { public CallId: string = ''; public Priority: number = 0; @@ -34,6 +36,11 @@ export class CallResultData { public Latitude: string = ''; public Longitude: string = ''; public CheckInTimersEnabled: boolean = false; + /** + * Contacts linked to the call (primary first) with pre-plan / alert / hazard indicators. Populated by + * GetCall and GetActiveCalls; absent on other list endpoints. GetCallSiteInfo carries the full site knowledge. + */ + public Contacts?: CallContactResultData[]; /** * Catalog field ids the server withheld from this response (ADP plan 7.2). Empty for a * department without the addon, and empty again once a grant reveals the record. diff --git a/src/models/v4/calls/callSiteInfoResult.ts b/src/models/v4/calls/callSiteInfoResult.ts new file mode 100644 index 00000000..d496e95c --- /dev/null +++ b/src/models/v4/calls/callSiteInfoResult.ts @@ -0,0 +1,46 @@ +import { BaseV4Request } from '../baseV4Request'; +import { type ContactFileResultData } from '../contactFiles/contactFilesResult'; +import { type ContactNoteResultData } from '../contacts/contactNoteResultData'; +import { type ContactHazardData, type ContactPreplanData } from '../contacts/contactPreplanResult'; + +/** A contact linked to a call, as carried on CallResultData.Contacts (Contacts plan Phase A, 3a). */ +export interface CallContactResultData { + ContactId: string; + /** Display name; the REDACTED placeholder in a protected department without a grant. */ + Name: string; + /** 0 = Person, 1 = Company */ + ContactType: number; + /** 0 = Primary, 1 = Additional */ + CallContactType: number; + HasPreplan: boolean; + AlertNoteCount: number; + HazardCount: number; +} + +/** One linked contact's site knowledge (v4 CallSiteContactData). */ +export interface CallSiteContactData { + ContactId: string; + Name: string; + ContactType: number; + CallContactType: number; + LocationGpsCoordinates?: string | null; + EntranceGpsCoordinates?: string | null; + PhoneNumber?: string | null; + Preplan: ContactPreplanData | null; + Hazards: ContactHazardData[]; + AlertNotes: ContactNoteResultData[]; + Attachments: ContactFileResultData[]; +} + +/** GET /Calls/GetCallSiteInfo payload. Additive-only contract (also the RMS NERIS prefill source). */ +export interface CallSiteInfoData { + CallId: string; + IsProtected: boolean; + ProtectedReason?: string | null; + /** Primary contact first */ + Contacts: CallSiteContactData[]; +} + +export class CallSiteInfoResult extends BaseV4Request { + public Data: CallSiteInfoData | null = null; +} diff --git a/src/models/v4/calls/dispatchedEventResultData.ts b/src/models/v4/calls/dispatchedEventResultData.ts index 36ff38fe..136e5c31 100644 --- a/src/models/v4/calls/dispatchedEventResultData.ts +++ b/src/models/v4/calls/dispatchedEventResultData.ts @@ -11,4 +11,12 @@ export class DispatchedEventResultData { public StatusText: string = ''; public StatusColor: string = ''; + + /** + * How a status entry was linked to the call (`StatusDestinationSources`): 1 Explicit (the sender + * chose the call), 2 CarryForward / 3 Dispatch / 4 Unit (auto-linked: sent without a call and + * linked by the server), 5 Inferred (set with no destination by a unit/person dispatched to the + * call). Null or absent on older rows and non-status entries. + */ + public DestinationSource?: number | null; } diff --git a/src/models/v4/calls/statusDestinationSources.ts b/src/models/v4/calls/statusDestinationSources.ts new file mode 100644 index 00000000..1b097252 --- /dev/null +++ b/src/models/v4/calls/statusDestinationSources.ts @@ -0,0 +1,15 @@ +/** + * How a status entry on a call's activity got linked to that call (server `StatusDestinationSources`). + */ +export enum StatusDestinationSources { + /** The sender chose the call. */ + Explicit = 1, + /** Sent without a call; linked from the sender's previous status. */ + CarryForward = 2, + /** Sent without a call; linked from the one open dispatch. */ + Dispatch = 3, + /** Sent without a call; linked from the unit the person rode. */ + Unit = 4, + /** Set with no destination by a unit or person dispatched to the call while working it. */ + Inferred = 5, +} diff --git a/src/models/v4/checklists/index.ts b/src/models/v4/checklists/index.ts new file mode 100644 index 00000000..1303e345 --- /dev/null +++ b/src/models/v4/checklists/index.ts @@ -0,0 +1,172 @@ +export interface ChecklistResult { + Data: T; + HasMore: boolean; + ContractVersion: number; +} +export interface ChecklistAccess { + DepartmentId: number; + UserId: string; + Enabled: boolean; + CanManage: boolean; + IsProtected: boolean; +} +export interface ChecklistTarget { + Type: number; + Id: string; + Name: string; + GroupId?: number | null; +} +export interface ChecklistCondition { + ItemId: string; + EqualsValue: string; +} +export interface ChecklistItem { + Id: string; + Name: string; + Instructions?: string | null; + Type: number; + Required: boolean; + Critical: boolean; + AllowNotApplicable: boolean; + RequireNoteOnFail: boolean; + RequirePhotoOnFail: boolean; + Weight: number; + Units?: string | null; + Minimum?: number | null; + Maximum?: number | null; + PassingValue: string; + Options: string[]; + VisibleWhen?: ChecklistCondition | null; + RequiredWhen?: ChecklistCondition | null; +} +export interface ChecklistForm { + Name: string; + Instructions?: string | null; + TargetType: number; + Category: number; + PassThreshold: number; + RequireLocation: boolean; + RequiresIndependentWitness: boolean; + Sections: { Id: string; Name: string; Items: ChecklistItem[] }[]; +} +export interface ChecklistAnswer { + ItemId: string; + Status: number; + Value?: string | null; + Note?: string | null; + NotApplicableReason?: string | null; +} +export interface ChecklistInput { + Revision: number; + Answers: ChecklistAnswer[]; + Note?: string | null; + LocationDescription?: string | null; + Latitude?: number | null; + Longitude?: number | null; + ClientCompletedOn?: string | null; +} +export interface ChecklistFile { + Id: string; + ItemId: string; + Name: string; + ContentType: string; + Size: number; + ScanState: number; +} +export interface ChecklistRun { + Id: string; + OccurrenceId: string | null; + DefinitionId: string; + VersionId: string; + VersionNumber: number; + Revision: number; + State: number; + IsProtected: boolean; + IsPreview: boolean; + CanEdit: boolean; + CreatedBy: string; + UpdatedOn: string; + SubmittedOn?: string | null; + SubmissionHash?: string | null; + WitnessUserId?: string | null; + WitnessAttestation?: string | null; + Score?: number | null; + Passed?: boolean | null; + Target: ChecklistTarget; + Form: ChecklistForm; + Input: ChecklistInput; + Files: ChecklistFile[]; +} +export interface ChecklistDefinition { + Id: string; + Revision: number; + VersionId: string; + VersionNumber: number; + Retired: boolean; + IsProtected: boolean; + UpdatedOn: string; + Form: ChecklistForm; + PublishedForm: ChecklistForm; + Targets: ChecklistTarget[]; +} +export interface ChecklistDue { + Id: string; + CompletionId: string; + DefinitionId: string; + VersionId: string; + Name: string; + Target: ChecklistTarget; + State: number; + Revision: number; + UpdatedOn: string; + PeriodStartUtc: string; + WindowEndUtc: string; + CanStart: boolean; +} +export interface ChecklistDuePage { + Occurrences: ChecklistDue[]; + Definitions: ChecklistDefinition[]; + HasMoreOccurrences: boolean; + HasMoreDefinitions: boolean; +} +export interface ChecklistHistory { + Id: string; + DefinitionId: string; + VersionId: string; + TargetType: number; + TargetId: string; + TargetName: string; + CreatedBy: string; + State: number; + Revision: number; + IsProtected: boolean; + UpdatedOn: string; + SubmittedOn?: string | null; + Score?: number | null; + Passed?: boolean | null; +} +export interface ChecklistStart { + CompletionId: string; + OccurrenceId?: string | null; + DefinitionId?: string; + VersionId?: string; + TargetId?: string; +} +export interface ChecklistImage { + id: string; + itemId: string; + name: string; + contentType: string; + base64: string; +} +export interface ChecklistDraft { + id: string; + scope: string; + start: ChecklistStart; + run: ChecklistRun; + input: ChecklistInput; + images: ChecklistImage[]; + queued: boolean; + submit: boolean; + error?: string; +} diff --git a/src/models/v4/contactFiles/contactFilesResult.ts b/src/models/v4/contactFiles/contactFilesResult.ts new file mode 100644 index 00000000..fc93c997 --- /dev/null +++ b/src/models/v4/contactFiles/contactFilesResult.ts @@ -0,0 +1,57 @@ +import { BaseV4Request } from '../baseV4Request'; + +/** ContactAttachmentTypes on the server. */ +export enum ContactFileType { + Document = 0, + PrePlan = 1, + FloorPlan = 2, + SitePhoto = 3, + SiteDrawing = 4, + Other = 5, +} + +/** + * One file attached to a contact (v4 ContactFileResultData). Name, file name and bytes are ADP catalog v12 + * fields: with protection enforced the anonymous signed Url is null for an enveloped file, and the bytes come + * back only through getContactFiles(includeData) with a grant. + */ +export interface ContactFileResultData { + IsProtected: boolean; + ProtectedReason?: string | null; + RedactedFields?: string[]; + Id: string; + ContactId: string; + Type: number; + TypeName: string; + Name: string; + FileName: string; + Mime: string; + Size: number; + /** Base64 file data, only when includeData was requested (null for a concealed protected file). */ + Data?: string | null; + /** Signed, expiring anonymous download URL; regenerated on every list call — never persist it. */ + Url?: string | null; + UserId?: string | null; + Timestamp: string; +} + +/** GET /ContactFiles/GetFilesForContact */ +export class ContactFilesResult extends BaseV4Request { + public Data: ContactFileResultData[] = []; +} + +/** POST /ContactFiles/UploadContactFile */ +export interface UploadContactFileInput { + ContactId: string; + Type: number; + Name?: string; + FileName: string; + /** Base64 file contents */ + Data: string; +} + +export class UploadContactFileResult extends BaseV4Request { + public Id: string = ''; +} + +export class DeleteContactFileResult extends BaseV4Request {} diff --git a/src/models/v4/contacts/contactPreplanResult.ts b/src/models/v4/contacts/contactPreplanResult.ts new file mode 100644 index 00000000..1fb72de9 --- /dev/null +++ b/src/models/v4/contacts/contactPreplanResult.ts @@ -0,0 +1,156 @@ +import { BaseV4Request } from '../baseV4Request'; + +/** + * A premise hazard on a contact's pre-incident plan (Contacts plan Phase A; v4 ContactHazardData). + * Text fields are ADP catalog v12: with protection enforced and no grant they read as REDACTED and + * RedactedFields names them. + */ +export interface ContactHazardData { + IsProtected: boolean; + ProtectedReason?: string | null; + RedactedFields?: string[]; + ContactPreplanHazardId: string; + ContactPreplanId: string; + ContactId: string; + /** ContactPreplanHazardTypes: General 0, Hazmat 1, Structural 2, Electrical 3, Biological 4, Animal 5, Occupant 6, Other 7 */ + HazardType: number; + HazardTypeName: string; + /** ContactPreplanHazardSeverities: Info 0, Caution 1, Danger 2 */ + Severity: number; + SeverityName: string; + Title: string; + Description?: string | null; + LocationDescription?: string | null; + /** "lat,lng" */ + GpsCoordinates?: string | null; + ShouldAlert: boolean; + AddedOnUtc: string; + AddedOn: string; + AddedByUserId?: string | null; + EditedOnUtc?: string | null; + EditedOn?: string | null; + EditedByUserId?: string | null; +} + +/** A contact's NFPA 1620 pre-incident plan (v4 ContactPreplanData). Enum fields carry the integer value. */ +export interface ContactPreplanData { + IsProtected: boolean; + ProtectedReason?: string | null; + RedactedFields?: string[]; + ContactPreplanId: string; + ContactId: string; + ConstructionType: number; + ConstructionTypeName: string; + RoofType: number; + RoofTypeName: string; + OccupancyType: number; + OccupancyTypeName: string; + OccupancyNotes?: string | null; + OccupancyHours?: string | null; + OccupantLoad?: number | null; + HasOccupantsNeedingAssistance: boolean; + OccupantsNeedingAssistanceNotes?: string | null; + GasShutoffLocation?: string | null; + ElectricShutoffLocation?: string | null; + WaterShutoffLocation?: string | null; + UtilityNotes?: string | null; + KnoxBoxLocation?: string | null; + GateCode?: string | null; + AlarmPanelLocation?: string | null; + AlarmCompany?: string | null; + AlarmCompanyPhone?: string | null; + AccessNotes?: string | null; + NearestHydrantLocation?: string | null; + RequiredFireFlowGpm?: number | null; + WaterSupplyNotes?: string | null; + EmergencyContactName?: string | null; + EmergencyContactPhone?: string | null; + SecondaryContactName?: string | null; + SecondaryContactPhone?: string | null; + HazmatOnSite: boolean; + GeneralHazardNotes?: string | null; + TacticalSummary?: string | null; + LastReviewedOnUtc?: string | null; + LastReviewedOn?: string | null; + ReviewedByUserId?: string | null; + NextReviewDueUtc?: string | null; + NextReviewDue?: string | null; + IsReviewOverdue: boolean; + AddedOnUtc: string; + AddedOn: string; + AddedByUserId?: string | null; + EditedOnUtc?: string | null; + EditedOn?: string | null; + EditedByUserId?: string | null; + Hazards: ContactHazardData[]; +} + +/** GET /Contacts/GetContactPreplan — Data is null when the contact has no plan. */ +export class ContactPreplanResult extends BaseV4Request { + public Data: ContactPreplanData | null = null; +} + +/** GET /Contacts/GetContactHazards */ +export class ContactHazardsResult extends BaseV4Request { + public Data: ContactHazardData[] = []; +} + +/** POST /Contacts/SaveContactPreplan — the whole plan is written; omitted text fields clear. */ +export interface SaveContactPreplanInput { + ContactId: string; + ConstructionType: number; + RoofType: number; + OccupancyType: number; + OccupancyNotes?: string; + OccupancyHours?: string; + OccupantLoad?: number | null; + HasOccupantsNeedingAssistance: boolean; + OccupantsNeedingAssistanceNotes?: string; + GasShutoffLocation?: string; + ElectricShutoffLocation?: string; + WaterShutoffLocation?: string; + UtilityNotes?: string; + KnoxBoxLocation?: string; + GateCode?: string; + AlarmPanelLocation?: string; + AlarmCompany?: string; + AlarmCompanyPhone?: string; + AccessNotes?: string; + NearestHydrantLocation?: string; + RequiredFireFlowGpm?: number | null; + WaterSupplyNotes?: string; + EmergencyContactName?: string; + EmergencyContactPhone?: string; + SecondaryContactName?: string; + SecondaryContactPhone?: string; + HazmatOnSite: boolean; + GeneralHazardNotes?: string; + TacticalSummary?: string; + MarkReviewed?: boolean; + NextReviewDueUtc?: string | null; +} + +/** POST /Contacts/SaveContactHazard — omit ContactPreplanHazardId to create. */ +export interface SaveContactHazardInput { + ContactPreplanHazardId?: string | null; + ContactId: string; + HazardType: number; + Severity: number; + Title: string; + Description?: string; + LocationDescription?: string; + GpsCoordinates?: string; + ShouldAlert: boolean; +} + +export class SaveContactPreplanResult extends BaseV4Request { + public Id: string = ''; +} + +export class SaveContactHazardResult extends BaseV4Request { + public Id: string = ''; +} + +export class DeleteContactPreplanResult extends BaseV4Request {} + +export class DeleteContactHazardResult extends BaseV4Request {} diff --git a/src/models/v4/contacts/contactResultData.ts b/src/models/v4/contacts/contactResultData.ts index 483658e6..1fd19d88 100644 --- a/src/models/v4/contacts/contactResultData.ts +++ b/src/models/v4/contacts/contactResultData.ts @@ -66,4 +66,33 @@ export interface ContactResultData { * across the list: a field withheld on one contact must not mark it on every other one. */ RedactedFields?: string[]; + IsProtected?: boolean; + ProtectedReason?: string | null; + /** The contact category's name and color (both list rows and the detail read). */ + CategoryName?: string | null; + CategoryColor?: string | null; + /** Resolved addresses (GetContactById only). */ + PhysicalAddress?: ContactAddressData | null; + MailingAddress?: ContactAddressData | null; + /** Mobile-visible custom field values with their labels, in form order (GetContactById only). */ + CustomFields?: ContactCustomFieldData[]; +} + +export interface ContactAddressData { + Address1?: string | null; + City?: string | null; + State?: string | null; + PostalCode?: string | null; + Country?: string | null; + /** One-line form for display and for a maps app. */ + Formatted?: string | null; +} + +export interface ContactCustomFieldData { + UdfFieldId: string; + Label: string; + Value: string; + FieldDataType: number; + GroupName?: string | null; + SortOrder: number; } diff --git a/src/models/v4/inventory/index.ts b/src/models/v4/inventory/index.ts new file mode 100644 index 00000000..72f3e441 --- /dev/null +++ b/src/models/v4/inventory/index.ts @@ -0,0 +1,99 @@ +// Inventory on an apparatus as the v4 Inventory controller returns it. Row text (item names, serials, count +// names) travels inside each row's Content JSON; lists come back one page of up to 500 rows at a time. + +export const InventoryLocationType = { Facility: 0, Station: 1, Unit: 2, Personnel: 3, Container: 4, External: 5 } as const; +export const InventoryAssetStatus = { InService: 0, Issued: 1, OutForRepair: 2, Damaged: 3, Lost: 4, Consumed: 5, Retired: 6 } as const; +export const InventoryCountStatus = { Draft: 0, AwaitingWitness: 1, Completed: 2, Cancelled: 3 } as const; + +export interface InventoryApiResult { + Data: T; + HasMore?: boolean; +} + +export interface InventoryPage { + Items: T[]; + HasMore: boolean; +} + +export interface InventoryFieldLocation { + Id: string; + Name?: string | null; + ParentLocationId?: string | null; + LocationType: number; + /** The unit's own holder location; compartments and kit containers hang off it. */ + IsRoot: boolean; +} + +export interface InventoryFieldAccess { + Enabled: boolean; + Migrated: boolean; + /** AdjustInventory at the unit: start, save and complete counts. */ + CanCount: boolean; + CanIssue: boolean; + CanTransfer: boolean; + UnitLocations: InventoryFieldLocation[]; +} + +interface InventoryRow { + Id: string; + Revision: number; + Content?: string | null; +} + +export interface InventoryAsset extends InventoryRow { + ItemId: string; + Status: number; + CurrentLocationId?: string | null; + ExpiresOn?: string | null; +} + +export interface InventoryStock extends InventoryRow { + ItemId: string; + LocationId: string; + LotId?: string | null; + Quantity: number; +} + +export interface InventoryIssuance extends InventoryRow { + IssuedToUserId?: string | null; + IssuedToUnitId?: number | null; + Quantity: number; + ExpectedReturnOn?: string | null; +} + +export interface InventoryEquipment { + Asset?: InventoryAsset | null; + Stock?: InventoryStock | null; + ItemName?: string | null; + UnitId?: number | null; + Issuances: InventoryIssuance[]; +} + +export interface InventoryCount extends InventoryRow { + LocationId?: string | null; + Status: number; + SnapshotOn?: string | null; + CompletedOn?: string | null; +} + +export interface InventoryCountLine extends InventoryRow { + CountId: string; + ItemId: string; + LocationId: string; + LotId?: string | null; + AssetId?: string | null; + ExpectedQuantity: number; + CountedQuantity?: number | null; +} + +export interface InventoryCountDetail { + Count: InventoryCount; + Lines: InventoryCountLine[]; +} + +export interface InventoryCountResult { + OperationId?: string | null; + /** A controlled-substance variance waits for a second person to witness it (web). */ + AwaitingWitness: boolean; + TransactionIds?: string[]; +} diff --git a/src/models/v4/operations/index.ts b/src/models/v4/operations/index.ts new file mode 100644 index 00000000..0ca658ad --- /dev/null +++ b/src/models/v4/operations/index.ts @@ -0,0 +1,281 @@ +// Field operations (Workforce & Business Operations plan, Phase C): deployments, daily time reports (DTR), +// resource usage readings and the CAL OES MARS F-42 hand-off. Shapes mirror the v4 Deployments, +// TimeReports, FieldCost and CalOesMars controllers. Money, rates and people never come through +// here beyond what the roster already shows; cost runs stay aggregate-only (decision 33). + +export interface OperationsResult { + Data: T; + Status: string; + PageSize: number; +} + +export const DeploymentStatus = { Planned: 0, Standby: 1, Active: 2, Demobilizing: 3, Completed: 4, Cancelled: 5 } as const; +export const DeploymentFinanceMode = { OperationalOnly: 0, CostRecovery: 1, Billable: 2 } as const; +export const TimeReportStatus = { Draft: 0, Submitted: 1, Approved: 2, Billed: 3, Void: 4 } as const; +/** Who a daily time report covers: the whole deployment (manager's DTR), one unit's crew (CTR) or one person. */ +export const TimeReportScope = { Deployment: 0, Crew: 1, Individual: 2 } as const; +export const ExpenseType = { PerDiemMeal: 0, Accommodation: 1, PrivateAccommodation: 2, Ferry: 3, Fuel: 4, SupplyRestock: 5, Other: 6 } as const; +export const TimeSubjectType = { Personnel: 0, Unit: 1, Equipment: 2 } as const; +export const TimeEntryType = { Deployment: 0, Standby: 1, Travel: 2 } as const; +export const UsagePhase = { Mobilization: 0, Standby: 1, Incident: 2, Return: 3 } as const; + +export interface DeploymentAccess { + Enabled: boolean; + CanManage: boolean; + CanApproveTimeReports: boolean; + ContractorBilling: boolean; +} + +export interface DeploymentUnit { + Id: string; + UnitId: number; + UnitName: string; + CallSign?: string | null; + IsActive: boolean; +} + +export interface DeploymentPersonnel { + Id: string; + UserId: string; + Name: string; + DeploymentUnitId?: string | null; + CertificationCode?: string | null; + CallSign?: string | null; + IsActive: boolean; +} + +export interface DeploymentEquipment { + Id: string; + DeploymentUnitId?: string | null; + Name: string; + IsActive: boolean; +} + +export interface Deployment { + Id: string; + Name: string; + Status: number; + FinanceMode: number; + CallId?: number | null; + IncidentNumber?: string | null; + ResourceOrderNumber?: string | null; + RequestNumber?: string | null; + CostCode?: string | null; + PointOfHire?: string | null; + StartOn?: string | null; + EndOn?: string | null; + LocalTimeZoneId?: string | null; + Currency?: string | null; + Units: DeploymentUnit[]; + Personnel: DeploymentPersonnel[]; + Equipment: DeploymentEquipment[]; + /** The caller's time scope, computed by the server (detail reads only). */ + TimeAccess?: DeploymentTimeAccess | null; +} + +/** + * What the signed-in person may do with this deployment's time: their own roster row (individual report), + * the deployed units they crew — on the deployment roster for that unit, or seated on the apparatus through + * an active unit role — (crew time report) and every subject id they may write. The server re-checks it all. + */ +export interface DeploymentTimeAccess { + CanManage: boolean; + CanApprove: boolean; + PersonnelId?: string | null; + CrewUnitIds: string[]; + WritableSubjectIds: string[]; + TimeZone?: string | null; +} + +export interface TimeEntry { + Id?: string | null; + SubjectType: number; + DeploymentPersonnelId?: string | null; + DeploymentUnitId?: string | null; + DeploymentEquipmentId?: string | null; + EntryType: number; + /** UTC instant from the server; the field apps read and write the local wall clock below. */ + StartTime?: string | null; + EndTime?: string | null; + /** Department-local wall clock "yyyy-MM-ddTHH:mm"; the server converts it in the department's zone. */ + StartLocal: string; + EndLocal: string; + PaidBreakMinutes: number; + UnpaidBreakMinutes: number; + CrewSizeSnapshot?: number | null; + CertificationCode?: string | null; + MileageKm?: number | null; + FuelDeductionLitres?: number | null; + AgencySuppliedMeals: boolean; + AgencySuppliedAccommodation: boolean; + Notes?: string | null; + SortOrder: number; + Hours?: number; +} + +export interface TimeReport { + Id: string; + DeploymentId: string; + ReportNumber: number; + ReportDate: string; + /** TimeReportScope value. */ + Scope: number; + DeploymentUnitId?: string | null; + DeploymentPersonnelId?: string | null; + /** The caller may write, sign and submit this report (its scope is theirs). */ + CanAct: boolean; + Status: number; + IncidentNumber?: string | null; + ResourceOrderNumber?: string | null; + RequestNumber?: string | null; + NoClear8?: boolean; + UnsafeConditionsStandDown?: boolean; + ContractorSignedByUserId?: string | null; + ContractorSignedOn?: string | null; + CustomerSignerName?: string | null; + CustomerSignedOn?: string | null; + SubmittedByUserId?: string | null; + SubmittedOn?: string | null; + ApprovedByUserId?: string | null; + ApprovedOn?: string | null; + Notes?: string | null; + Entries: TimeEntry[]; +} + +export interface Expense { + Id: string; + DeploymentId: string; + TimeReportId?: string | null; + ExpenseDate: string; + ExpenseType: number; + MealCode?: string | null; + City?: string | null; + Description?: string | null; + Amount: number; + Currency?: string | null; + PreApproved: boolean; + Billable: boolean; + ReceiptAttachmentId?: number | null; + AddedByUserId?: string | null; + AddedOn?: string | null; +} + +export interface ExpenseInput { + Id?: string | null; + DeploymentId: string; + TimeReportId?: string | null; + ExpenseDate: string; + ExpenseType: number; + City?: string | null; + Description?: string | null; + Amount: number; + Currency?: string | null; + /** Receipt photo, base64 (the server files it as a Receipt attachment). */ + ReceiptData?: string | null; + ReceiptFileName?: string | null; + ReceiptContentType?: string | null; +} + +export interface TimeReportIssue { + Code: string; + SubjectId?: string | null; + EntryId?: string | null; + Detail?: string | null; +} + +/** A time report action answers with the report plus the server's own validation issues. */ +export interface TimeReportResponse extends OperationsResult { + Errors?: TimeReportIssue[]; + Warnings?: TimeReportIssue[]; +} + +export interface FieldCostAccess { + Enabled: boolean; + CanViewInternalCosts: boolean; + CanRecordUsage: boolean; +} + +export interface ResourceUsage { + Id: string; + DeploymentId?: string | null; + CallId?: number | null; + UnitId: number; + UsageDate: string; + Phase: number; + DistanceUnit?: string | null; + OriginalDistance?: number | null; + EngineHours?: number | null; + OperatingHours?: number | null; + FuelQuantity?: number | null; + FuelUnit?: string | null; + Source: number; + NeedsReview: boolean; + ReviewReason?: string | null; +} + +export interface ResourceUsageInput { + DeploymentId?: string | null; + CallId?: number | null; + UnitId: number; + UsageDate: string; + Phase: number; + StartOdometer?: number | null; + EndOdometer?: number | null; + DistanceUnit?: string | null; + Distance?: number | null; + StartEngineMeter?: number | null; + EndEngineMeter?: number | null; + EngineHours?: number | null; + OperatingHours?: number | null; + IdleHours?: number | null; + FuelQuantity?: number | null; + FuelUnit?: string | null; +} + +export interface CalOesMarsAccess { + Enabled: boolean; + CanView: boolean; + CanManage: boolean; + CanSubmit: boolean; + AuthorityProfileCode?: string | null; + PortalUrl?: string | null; +} + +export interface CalOesMarsIssue { + Box?: string | null; + Code: string; + Detail?: string | null; +} + +export interface CalOesMarsValidation { + WorkItemId: string; + ValidatedOn: string; + Errors: CalOesMarsIssue[]; + Warnings: CalOesMarsIssue[]; + IsReadyForPortal: boolean; +} + +export interface CalOesMarsQueueItem { + Id: string; + DeploymentId: string; + DeploymentName?: string | null; + RecordType: number; + RecordTypeName?: string | null; + LocalState: number; + LocalStateName?: string | null; + IncidentNumber?: string | null; + RequestNumber?: string | null; + MarsRecordId?: string | null; + ObservedExternalStatus?: string | null; + ErrorCount: number; + WarningCount: number; + IsMine: boolean; + UpdatedOn?: string | null; +} + +export interface CalOesMarsWorkItem extends CalOesMarsQueueItem { + IsLocallyEditable: boolean; + IsExternal: boolean; + Validation?: CalOesMarsValidation | null; + RowVersion: number; +} diff --git a/src/models/v4/records/deployments.ts b/src/models/v4/records/deployments.ts new file mode 100644 index 00000000..9d589754 --- /dev/null +++ b/src/models/v4/records/deployments.ts @@ -0,0 +1,198 @@ +// Deployments and external ordering-system connectors (RMS plan section 4.1, RMS-1C). Mirrors the v4 +// RecordDeployments and RecordDeploymentConnectors controllers. Everything here is read from the +// server; the only command a field client may issue is "read the feed now", and only a department +// administrator may issue it. Creating or changing a connector — and above all entering its +// credential — stays Web-first, so those inputs are deliberately absent from this module. + +/** Resgrid.Model.RmsExternalOrderStatus, as the API names it. */ +export type RecordDeploymentStatus = 'Open' | 'Mobilized' | 'Released' | 'ClosedOut'; + +/** Resgrid.Model.RmsDeploymentFillStatus, as the API names it. */ +export type RecordDeploymentFillStatus = 'Requested' | 'Accepted' | 'Declined' | 'Mobilized' | 'CheckedIn' | 'Assigned' | 'Released' | 'Demobilized' | 'Returned'; + +/** Resgrid.Model.RmsExternalOrderOwnership: who maintains the source view of an order. */ +export const RmsExternalOrderOwnership = { + Manual: 'manual', + Connector: 'connector', +} as const; + +export interface RecordDeploymentFillData { + FillId: string; + RequestNumber: string; + ParentRequestNumber?: string | null; + RequestCategory?: string | null; + FillNumber?: string | null; + ResourceKind?: string | null; + ResourceType?: string | null; + ResourceTypeScheme?: string | null; + Position?: string | null; + PositionScheme?: string | null; + IsTrainee: boolean; + HomeUnit?: string | null; + HostAgency?: string | null; + AgencyUnitId?: string | null; + PointOfHire?: string | null; + CostCode?: string | null; + AgreementReference?: string | null; + AssignedUserId?: string | null; + AssignedUnitId?: number | null; + Status: RecordDeploymentFillStatus | string; + DeclineReason?: string | null; + RequestedOn?: string | null; + NeededOn?: string | null; + FilledOn?: string | null; + MobilizedOn?: string | null; + CheckedInOn?: string | null; + AssignedOn?: string | null; + ReleasedOn?: string | null; + DemobilizedOn?: string | null; + ReturnedOn?: string | null; + CapturedOffsetMinutes?: number | null; + Notes?: string | null; + RowVersion: number; +} + +export interface RecordDeploymentData { + OrderId: string; + RecordId?: string | null; + RecordNumber?: string | null; + RecordState?: string | null; + ProfileKey: string; + ProfileVersion: number; + HomeProfileKey?: string | null; + HostProfileKey?: string | null; + SourceScheme: string; + SourceSystem?: string | null; + OrderNumber: string; + IncidentName: string; + IncidentNumber?: string | null; + IncidentCountry?: string | null; + IncidentSubdivision?: string | null; + OrderingOffice?: string | null; + DispatchOffice?: string | null; + RequestingAgency?: string | null; + ReceivingAgency?: string | null; + SendingAgency?: string | null; + DepartmentRole?: string | null; + CostCode?: string | null; + AgreementReference?: string | null; + CurrencyCode?: string | null; + MeasurementSystem?: string | null; + TimeZoneId?: string | null; + CapturedOffsetMinutes?: number | null; + SourceCapturedOn?: string | null; + SourceVersion?: string | null; + ArtifactFileName?: string | null; + ArtifactContentType?: string | null; + ArtifactChecksum?: string | null; + HasArtifact: boolean; + ArtifactSafeUrl?: string | null; + Status: RecordDeploymentStatus | string; + /** manual or connector; see RmsExternalOrderOwnership. */ + OwnershipMarker?: string | null; + ConnectorId?: string | null; + MobilizedOn?: string | null; + ReleasedOn?: string | null; + ClosedOutOn?: string | null; + CloseoutNotes?: string | null; + AllReturned: boolean; + IsPreview: boolean; + ProvenanceStatement?: string | null; + CreatedOn: string; + ModifiedOn: string; + RowVersion: number; + ETag?: string | null; + Fills: RecordDeploymentFillData[]; +} + +/** One connector as the server describes it; the credential and the inbound token never travel. */ +export interface RecordDeploymentConnectorData { + Id: string; + ProviderKey: string; + Name: string; + SourceSystem?: string | null; + SourceScheme: string; + ProfileKey: string; + BaseUrl: string; + CredentialKind: string; + CredentialHeaderName?: string | null; + HasCredential: boolean; + HasInboundToken: boolean; + ReadEnabled: boolean; + /** Always false in this release: write authority to an external ordering system is refused. */ + WriteEnabled: boolean; + PollIntervalMinutes: number; + MaxRequestsPerHour: number; + RequestsThisHour: number; + TermsReference?: string | null; + TermsAcknowledgedOn?: string | null; + TermsAcknowledgedByUserId?: string | null; + IsEnabled: boolean; + IsReadyToRun: boolean; + LastPolledOn?: string | null; + LastSuccessOn?: string | null; + LastError?: string | null; + ConsecutiveFailures: number; + CreatedOn: string; + ModifiedOn: string; + RowVersion: number; +} + +export const RmsConnectorRunTriggers = { + Poll: 'poll', + Manual: 'manual', + Inbound: 'inbound', +} as const; + +export const RmsConnectorRunOutcomes = { + Ok: 'ok', + Failed: 'failed', + RateLimited: 'rate_limited', + Disabled: 'disabled', + Rejected: 'rejected', +} as const; + +export type RmsConnectorRunOutcome = (typeof RmsConnectorRunOutcomes)[keyof typeof RmsConnectorRunOutcomes]; + +export interface RecordDeploymentConnectorRunData { + Id: string; + ConnectorId: string; + Trigger: string; + TriggeredByUserId?: string | null; + StartedOn: string; + FinishedOn?: string | null; + Outcome: RmsConnectorRunOutcome | string; + Error?: string | null; + RequestCount: number; + OrdersSeen: number; + OrdersCreated: number; + SnapshotsRecorded: number; + RequestsAdded: number; + Unchanged: number; + Rejected: number; + Conflicts: number; + SourceVersion?: string | null; + Messages: string[]; +} + +/** Where the source's latest snapshot and the department's own record disagree. Never applied by a client. */ +export type RecordDeploymentReconciliationKind = + | 'source_released_local_not_returned' + | 'source_closed_local_open' + | 'source_request_missing_locally' + | 'local_fill_missing_in_source' + | 'source_status_ahead' + | 'source_status_behind'; + +export interface RecordDeploymentReconciliationData { + ConnectorId?: string | null; + OrderId: string; + RecordId?: string | null; + OrderNumber: string; + RequestNumber?: string | null; + Kind: RecordDeploymentReconciliationKind | string; + SourceStatus?: string | null; + LocalStatus?: string | null; + SourceVersion?: string | null; + SourceCapturedOn?: string | null; +} diff --git a/src/models/v4/records/index.ts b/src/models/v4/records/index.ts new file mode 100644 index 00000000..20237d9f --- /dev/null +++ b/src/models/v4/records/index.ts @@ -0,0 +1,444 @@ +// Field Records contracts (RMS plan RMS-1D). Mirrors Resgrid.Model FieldRecordCatalogV1 and the v4 +// FieldRecords / Records controllers. Every filter below is applied server-side from the authenticated +// principal: sending a different origin, context or capability can only narrow what comes back. + +/** Resgrid.Model.RmsOriginClient. */ +export enum RmsOriginClient { + System = 0, + Web = 1, + Responder = 2, + Unit = 3, + IncidentCommand = 4, + Dispatch = 5, + Api = 6, +} + +/** Resgrid.Model.RmsRecordState. */ +export enum RmsRecordState { + Draft = 1, + ReadyForReview = 2, + Returned = 3, + Finalized = 4, + Amended = 5, + Voided = 6, + Cancelled = 7, +} + +/** Resgrid.Model.RmsFieldType. A control this client cannot render fails closed for authoring. */ +export enum RmsFieldType { + ShortText = 1, + LongText = 2, + Integer = 3, + Decimal = 4, + Boolean = 5, + Date = 6, + DateTime = 7, + Duration = 8, + SingleSelect = 9, + MultiSelect = 10, + Address = 11, + Person = 12, + Unit = 13, + Group = 14, + Contact = 15, + Attachment = 16, + Signature = 17, + ExternalReference = 18, + Currency = 19, + Quantity = 20, + CountrySubdivision = 21, + CallReference = 22, +} + +/** Resgrid.Model.RmsFieldClassification. */ +export enum RmsFieldClassification { + Standard = 1, + Restricted = 2, + Protected = 3, +} + +/** Resgrid.Model.RmsRuleEffect. */ +export enum RmsRuleEffect { + Show = 1, + Require = 2, +} + +/** Resgrid.Model.RmsRuleOperator. */ +export enum RmsRuleOperator { + Equals = 1, + NotEquals = 2, + InSet = 3, + NotInSet = 4, + IsEmpty = 5, + IsNotEmpty = 6, + InRange = 7, + And = 20, + Or = 21, +} + +/** Renderer capability this build reports. Bump only when every control of the next tier renders. */ +export const RecordsClientCapabilities = { + Locked: 'records.v1', + Configurable: 'records.v1b', + Packs: 'records.v1c', +} as const; + +export const FieldRecordContractVersion = 'field-catalog.v1'; +export const FieldRecordSyncContractVersion = 'field-sync.v1'; + +/** Launch contexts a definition surface may name. */ +export const FieldRecordLaunchContexts = { + None: 'none', + Call: 'call', + Unit: 'unit', + Contact: 'contact', + Checklist: 'checklist', + WorkOrder: 'workorder', + Command: 'command', +} as const; + +/** Why the server withheld a definition. Codes only — never a reason a client can act around. */ +export const FieldRecordExclusionReasons = { + OriginNotField: 'origin_not_field', + ModuleDisabled: 'module_disabled', + RecordsNotUsable: 'records_not_usable', + AppDisabled: 'app_disabled', + AppVersionTooOld: 'app_version_too_old', + NotMember: 'not_member', + SurfaceNotEnabled: 'surface_not_enabled', + ContextNotAllowed: 'context_not_allowed', + ContextNotVerified: 'context_not_verified', + CapabilityUnsupported: 'capability_unsupported', + Retired: 'retired', + NotPublished: 'not_published', + ProtectedDataUnavailable: 'protected_data_unavailable', +} as const; + +export interface FieldRecordContextInput { + CallId?: number | null; + UnitId?: number | null; + GroupId?: number | null; + CommandRole?: string | null; + ContactId?: number | null; +} + +export interface FieldRecordCatalogInput { + OriginClient: number; + AppVersion?: string | null; + ClientCapability?: string | null; + Context?: FieldRecordContextInput | null; +} + +export interface FieldRecordPreflightData { + ContractVersion: string; + SyncContractVersion: string; + OriginClient: string; + Ok: boolean; + Reasons: string[]; + ModuleEnabled: boolean; + RecordsUsable: boolean; + AppEnabled: boolean; + MinimumAppVersion?: string | null; + AppVersion?: string | null; + ClientCapability?: string | null; + ProtectionState?: string | null; + ServerTimestampMs: number; +} + +export interface FieldRecordCatalogEntry { + DefinitionKey: string; + Version: number; + Name: string; + Category?: string | null; + Locked: boolean; + RecordType?: number | null; + LifecyclePreset?: string | null; + LaunchContexts: string[]; + AllowOffline: boolean; + AllowAttachments: boolean; + MinimumAppVersion?: string | null; + MinimumClientCapability?: string | null; + Restricted: boolean; + /** Definition seals values: authoring needs a live grant and nothing is cached offline. */ + RequiresProtectedGrant: boolean; + SchemaChecksum?: string | null; + PrefillVersion: number; + SupportsPrefill: boolean; +} + +export interface FieldRecordCatalogExclusion { + DefinitionKey: string; + Reason: string; +} + +export interface FieldRecordCatalogData { + ContractVersion: string; + OriginClient: string; + Ok: boolean; + Reasons: string[]; + ContextKind?: string | null; + ContextVerified: boolean; + ProtectionState?: string | null; + ScopeStamp?: string | null; + Definitions: FieldRecordCatalogEntry[]; + Exclusions: FieldRecordCatalogExclusion[]; + ServerTimestampMs: number; +} + +export interface FieldRecordPrefillValue { + SectionKey?: string | null; + FieldKey: string; + Value?: string | null; + ReferenceType?: string | null; + ReferenceId?: string | null; +} + +export interface FieldRecordPrefillProvenance { + FieldKey: string; + Source: string; + SourceId?: string | null; + CapturedOn: string; +} + +export interface FieldRecordPrefillData { + ContractVersion: string; + DefinitionKey: string; + Version: number; + PrefillVersion: number; + CallId?: number | null; + UnitId?: number | null; + StationGroupId?: number | null; + Values: FieldRecordPrefillValue[]; + Provenance: FieldRecordPrefillProvenance[]; + SuggestedParticipantUserIds: string[]; + SuggestedUnitIds: number[]; + CalculatedOn: string; +} + +export interface RecordSummaryData { + RecordId: string; + RecordKind?: string | null; + RecordNumber?: string | null; + DraftReference?: string | null; + DefinitionKey?: string | null; + DefinitionVersion: number; + RecordType?: number | null; + State: number; + StateName?: string | null; + OccurredOn?: string | null; + CreatedOn: string; + FinalizedOn?: string | null; + ModifiedOn: string; + StationGroupId?: number | null; + CallId?: number | null; + CallNumber?: string | null; + AuthorUserId?: string | null; + OwnerUserId?: string | null; + ReviewerUserId?: string | null; + DisplaySummary?: string | null; + IsLegacy?: boolean; + RowVersion: number; + IsTombstone?: boolean; + DeletedOn?: string | null; +} + +export interface FieldRecordAssignmentData { + AssignmentId: string; + RecordId: string; + AssigneeKind: string; + AssigneeUserId?: string | null; + AssigneeUnitId?: number | null; + AssigneeGroupId?: number | null; + AssigneeRole?: string | null; + Purpose: string; + Note?: string | null; + DueOn?: string | null; + State: string; + AcknowledgedOn?: string | null; + CompletedOn?: string | null; + OriginClient?: string | null; + CreatedOn: string; + CreatedByUserId?: string | null; + RowVersion: number; +} + +export interface FieldRecordSyncData { + ContractVersion: string; + Ok: boolean; + Reasons: string[]; + ScopeStamp?: string | null; + ResetRequired: boolean; + Since: number; + ServerTimestampMs: number; + ServerCursorId?: string | null; + HasMore: boolean; + Catalog?: FieldRecordCatalogData | null; + Records: RecordSummaryData[]; + /** Record ids the caller may no longer read; evict any cached copy. */ + Tombstones: string[]; + Drafts: RecordSummaryData[]; + Assignments: FieldRecordAssignmentData[]; +} + +// --------------------------------------------------------------------------- +// Definition schema (RMS-1B). The renderer walks this; it never invents a field. +// --------------------------------------------------------------------------- + +export interface RecordOptionSchema { + Key: string; + Label: string; + IsInactive?: boolean; +} + +export interface RecordConditionSchema { + Operator: RmsRuleOperator; + FieldKey?: string | null; + Value?: string | null; + Values?: string[] | null; + Min?: number | null; + Max?: number | null; + Conditions?: RecordConditionSchema[] | null; +} + +export interface RecordRuleSchema { + Effect: RmsRuleEffect; + Condition?: RecordConditionSchema | null; +} + +export interface RecordFieldSchema { + Key: string; + Label: string; + Help?: string | null; + Type: RmsFieldType; + Required?: boolean; + RequiredToFinalize?: boolean; + Classification: RmsFieldClassification; + Options?: RecordOptionSchema[] | null; + Rules?: RecordRuleSchema[] | null; + MaxLength?: number | null; + Min?: number | null; + Max?: number | null; + DefaultCurrency?: string | null; + UnitDimension?: string | null; + DefaultUnitCode?: string | null; + ReferenceType?: string | null; +} + +export interface RecordSectionSchema { + Key: string; + Label: string; + Help?: string | null; + Repeating?: boolean; + MinRows?: number | null; + MaxRows?: number | null; + Rules?: RecordRuleSchema[] | null; + Fields: RecordFieldSchema[]; +} + +export interface RecordDefinitionSchema { + Sections: RecordSectionSchema[]; +} + +export interface RecordDefinitionVersionData { + DefinitionKey: string; + Version: number; + Name?: string | null; + State?: number; + LifecyclePreset?: number; + MinimumClientCapability?: string | null; + SchemaChecksum?: string | null; + Schema?: RecordDefinitionSchema | null; + ETag?: string | null; +} + +/** One posted value. A repeating row groups its cells by RowKey. */ +export interface RecordValueInput { + SectionKey: string; + FieldKey: string; + RowKey?: string | null; + Ordinal?: number; + Value?: string | null; + Values?: string[] | null; + ReferenceType?: string | null; + ReferenceId?: string | null; + UnitCode?: string | null; +} + +export interface RecordCreateDraftInput { + DefinitionKey: string; + CallId?: number | null; + StationGroupId?: number | null; + StartedOn?: string | null; + Values?: RecordValueInput[]; + IdempotencyKey?: string | null; + ClientRecordId?: string | null; + OriginClient?: number | null; +} + +export interface RecordSaveDraftInput { + RecordId: string; + RowVersion?: number | null; + Values?: RecordValueInput[]; + IdempotencyKey?: string | null; + OriginClient?: number | null; +} + +export interface RecordCommandInput { + RecordId: string; + RowVersion?: number | null; + IdempotencyKey?: string | null; + ReasonCode?: string | null; + ReasonText?: string | null; + Attested?: boolean; + AttestationStatementVersion?: string | null; + OriginClient?: number | null; +} + +export interface RecordData { + RecordId: string; + RecordNumber?: string | null; + DraftReference?: string | null; + DefinitionKey?: string | null; + DefinitionVersion: number; + State: number; + StateName?: string | null; + RowVersion: number; + CallId?: number | null; + StationGroupId?: number | null; + OwnerUserId?: string | null; + AuthorUserId?: string | null; + CreatedOn?: string | null; + ModifiedOn?: string | null; + Values?: RecordValuesData | null; + [key: string]: unknown; +} + +export interface RecordValueCellData { + SectionKey: string; + FieldKey: string; + RowKey?: string | null; + Ordinal?: number; + Value?: string | null; + Values?: string[] | null; + ReferenceType?: string | null; + ReferenceId?: string | null; + UnitCode?: string | null; + IsWithheld?: boolean; +} + +export interface RecordValuesData { + DefinitionKey?: string | null; + Version?: number; + Cells: RecordValueCellData[]; +} + +/** Every v4 payload is wrapped like this. */ +export interface RecordsApiResult { + Data?: T; + PageSize?: number; + Status?: string; + Timestamp?: string; +} + +/** How a conflict is presented; the client never replays one silently. */ +export type FieldRecordConflictKind = 'etag' | 'permission' | 'scope' | 'definition-retired' | 'protected-data' | 'app-version'; diff --git a/src/services/__tests__/offline-event-manager.service.test.ts b/src/services/__tests__/offline-event-manager.service.test.ts index eee43f94..dbfe08d1 100644 --- a/src/services/__tests__/offline-event-manager.service.test.ts +++ b/src/services/__tests__/offline-event-manager.service.test.ts @@ -453,6 +453,99 @@ describe('OfflineEventManager', () => { }); }); + describe('unit status replay rejected by the server', () => { + const httpError = (status: number) => Object.assign(new Error(`Request failed with status code ${status}`), { isAxiosError: true, response: { status } }); + + const networkError = () => Object.assign(new Error('Network Error'), { isAxiosError: true, code: 'ERR_NETWORK', request: {} }); + + const buildEvent = (respondingTo: string, respondingToType: number | null) => ({ + id: 'status-event', + type: QueuedEventType.UNIT_STATUS, + status: QueuedEventStatus.PENDING, + data: { + unitId: 'unit-1', + statusType: '3', + note: 'On scene', + respondingTo, + respondingToType, + // When the crew tapped the status — not when the queue drains + timestamp: '2026-09-23T10:00:00.000Z', + timestampUtc: 'Wed, 23 Sep 2026 10:00:00 GMT', + }, + retryCount: 0, + maxRetries: 3, + createdAt: Date.now(), + }); + + const processEvent = (event: ReturnType) => (offlineEventManager as any).processEvent(event); + + it('replays once without the destination when the server rejects it with 400, keeping the original time', async () => { + mockSaveUnitStatus.mockRejectedValueOnce(httpError(400)).mockResolvedValueOnce({} as any); + + await processEvent(buildEvent('555', 2)); + + expect(mockSaveUnitStatus).toHaveBeenCalledTimes(2); + expect(mockSaveUnitStatus).toHaveBeenNthCalledWith(1, expect.objectContaining({ RespondingTo: '555', RespondingToType: 2, Timestamp: '2026-09-23T10:00:00.000Z' })); + expect(mockSaveUnitStatus).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ Type: '3', Note: 'On scene', RespondingTo: '0', RespondingToType: null, Timestamp: '2026-09-23T10:00:00.000Z', TimestampUtc: 'Wed, 23 Sep 2026 10:00:00 GMT' }) + ); + expect(mockStoreState.updateEventStatus).toHaveBeenLastCalledWith('status-event', QueuedEventStatus.COMPLETED); + }); + + it('stops retrying when the replay without destination is rejected too', async () => { + mockSaveUnitStatus.mockRejectedValueOnce(httpError(400)).mockRejectedValueOnce(httpError(400)); + + await processEvent(buildEvent('555', 2)); + + expect(mockSaveUnitStatus).toHaveBeenCalledTimes(2); + expect(mockStoreState.updateEventStatus).toHaveBeenLastCalledWith('status-event', QueuedEventStatus.FAILED, expect.stringContaining('HTTP 400'), { permanent: true }); + }); + + it('stops retrying a status without destination that the server rejects with 400', async () => { + mockSaveUnitStatus.mockRejectedValueOnce(httpError(400)); + + await processEvent(buildEvent('0', null)); + + expect(mockSaveUnitStatus).toHaveBeenCalledTimes(1); + expect(mockStoreState.updateEventStatus).toHaveBeenLastCalledWith('status-event', QueuedEventStatus.FAILED, expect.any(String), { permanent: true }); + }); + + it.each([[403], [404]])('stops retrying on other client errors (%p) without a destination fallback', async (status) => { + mockSaveUnitStatus.mockRejectedValueOnce(httpError(status)); + + await processEvent(buildEvent('555', 2)); + + expect(mockSaveUnitStatus).toHaveBeenCalledTimes(1); + expect(mockStoreState.updateEventStatus).toHaveBeenLastCalledWith('status-event', QueuedEventStatus.FAILED, expect.stringContaining(`HTTP ${status}`), { permanent: true }); + }); + + it.each([ + ['a network error', networkError()], + ['a server error', httpError(500)], + ['an expired session', httpError(401)], + ['throttling', httpError(429)], + ])('keeps the normal retry for %s', async (_label, error) => { + mockSaveUnitStatus.mockRejectedValueOnce(error); + + await processEvent(buildEvent('555', 2)); + + expect(mockSaveUnitStatus).toHaveBeenCalledTimes(1); + expect(mockStoreState.updateEventStatus).toHaveBeenLastCalledWith('status-event', QueuedEventStatus.FAILED, (error as Error).message); + }); + + it('queues the time the status was recorded rather than the enqueue time', () => { + const recordedAt = new Date('2026-09-23T10:00:00.000Z'); + + offlineEventManager.queueUnitStatusEvent('unit-1', '3', '', '555', 2, [], undefined, recordedAt); + + expect(mockStoreState.addEvent).toHaveBeenCalledWith( + QueuedEventType.UNIT_STATUS, + expect.objectContaining({ respondingTo: '555', respondingToType: 2, timestamp: '2026-09-23T10:00:00.000Z', timestampUtc: 'Wed, 23 Sep 2026 10:00:00 GMT' }) + ); + }); + }); + describe('app state handling', () => { it('should have set up app state listener during initialization', () => { // The AppState listener should have been set up when the module was imported diff --git a/src/services/offline-event-manager.service.ts b/src/services/offline-event-manager.service.ts index 36e58187..2177cbcd 100644 --- a/src/services/offline-event-manager.service.ts +++ b/src/services/offline-event-manager.service.ts @@ -1,3 +1,4 @@ +import axios from 'axios'; import { AppState, type AppStateStatus } from 'react-native'; import { saveCallImage } from '@/api/calls/callFiles'; @@ -18,6 +19,26 @@ import { SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocation import { SaveUnitStatusInput, SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; import { setOfflineQueueActivityListener, useOfflineQueueStore } from '@/stores/offline-queue/store'; +/** + * A replay the server refused outright. Sending the same payload again can only fail the same way, + * so the event is parked as permanently failed instead of burning its retries. + */ +class NonRetryableEventError extends Error { + constructor( + message: string, + public readonly status: number + ) { + super(message); + this.name = 'NonRetryableEventError'; + } +} + +const getHttpStatus = (error: unknown): number | null => (axios.isAxiosError(error) && error.response ? error.response.status : null); + +// 4xx the server will keep returning for the same payload. 401 runs through the client's token +// refresh and 408/429 are timeouts/throttling, so those stay retryable like network failures. +const isNonRetryableClientError = (status: number | null): status is number => status !== null && status >= 400 && status < 500 && status !== 401 && status !== 408 && status !== 429; + class OfflineEventManager { private static instance: OfflineEventManager; private processingInterval: ReturnType | null = null; @@ -141,9 +162,11 @@ class OfflineEventManager { altitudeAccuracy?: string; speed?: string; heading?: string; - } + }, + // When the crew set the status. Replays send this, so a queued status keeps its real time. + recordedAt?: Date ): string { - const date = new Date(); + const date = recordedAt ?? new Date(); const data = { unitId, statusType, @@ -312,6 +335,13 @@ class OfflineEventManager { try { switch (event.type) { + case QueuedEventType.CHECKLIST_COMPLETION: { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { flushChecklistDraft } = require('@/stores/checklists/store') as typeof import('@/stores/checklists/store'); + if (typeof event.data.scope !== 'string' || typeof event.data.id !== 'string') throw new Error('checklist_invalid_reference'); + await flushChecklistDraft(event.data.scope, event.data.id); + break; + } case QueuedEventType.UNIT_STATUS: await this.processUnitStatusEvent(event as QueuedUnitStatusEvent); break; @@ -343,6 +373,17 @@ class OfflineEventManager { } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + if (error instanceof NonRetryableEventError) { + // Stop here rather than looping on a payload the server will never accept. + store.updateEventStatus(event.id, QueuedEventStatus.FAILED, errorMessage, { permanent: true }); + + logger.error({ + message: 'Queued event rejected by server, not retrying', + context: { eventId: event.id, type: event.type, status: error.status, error: errorMessage }, + }); + return; + } + store.updateEventStatus(event.id, QueuedEventStatus.FAILED, errorMessage); logger.error({ @@ -394,7 +435,46 @@ class OfflineEventManager { }); } - await saveUnitStatus(input); + try { + await saveUnitStatus(input); + } catch (error) { + const status = getHttpStatus(error); + const hasDestination = !!input.RespondingTo && input.RespondingTo !== '0'; + + if (status !== 400 || !hasDestination) { + throw this.toReplayError(error, event); + } + + // SaveUnitStatus rejects the whole status with 400 when its destination is no longer valid — + // typically the call closed before the queue drained. The status and the time it happened + // still belong on the unit's timeline, so replay it once without the destination (keeping the + // original timestamp) instead of retrying a payload that can never succeed and then dropping it. + logger.warn({ + message: 'Queued unit status rejected for its destination, replaying without destination', + context: { eventId: event.id, unitId: input.Id, statusType: input.Type, respondingTo: input.RespondingTo, respondingToType: input.RespondingToType }, + }); + + const withoutDestination: SaveUnitStatusInput = { ...input, RespondingTo: '0', RespondingToType: null }; + + try { + await saveUnitStatus(withoutDestination); + } catch (fallbackError) { + throw this.toReplayError(fallbackError, event); + } + } + } + + /** Classify a failed unit status replay: permanent server rejections stop retrying. */ + private toReplayError(error: unknown, event: QueuedUnitStatusEvent): unknown { + const status = getHttpStatus(error); + + if (!isNonRetryableClientError(status)) { + // Network failures, 5xx and auth/throttling keep the normal retry-with-backoff. + return error; + } + + // processEvent logs it and parks the event as permanently failed. + return new NonRetryableEventError(`Unit status ${event.data.statusType} for unit ${event.data.unitId} rejected by server (HTTP ${status})`, status); } /** diff --git a/src/stores/calls/__tests__/site-info-store.test.ts b/src/stores/calls/__tests__/site-info-store.test.ts new file mode 100644 index 00000000..bf16b582 --- /dev/null +++ b/src/stores/calls/__tests__/site-info-store.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; + +import { getCallSiteInfo } from '@/api/calls/callSiteInfo'; +import { type CallSiteInfoData } from '@/models/v4/calls/callSiteInfoResult'; + +import { useSiteInfoStore } from '../site-info-store'; + +jest.mock('@/api/calls/callSiteInfo'); +jest.mock('@/lib/logging', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const mockGetCallSiteInfo = getCallSiteInfo as jest.MockedFunction; + +const makeSiteInfo = (callId: string): CallSiteInfoData => ({ + CallId: callId, + IsProtected: false, + Contacts: [ + { + ContactId: 'c1', + CallContactType: 0, + ContactType: 1, + Name: 'Acme Warehouse', + PhoneNumber: '5551234', + EntranceGpsCoordinates: null, + AlertNotes: [], + Preplan: null, + Hazards: [], + Attachments: [], + }, + ], +}); + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +}; + +describe('useSiteInfoStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + useSiteInfoStore.getState().reset(); + }); + + it('starts empty', () => { + const state = useSiteInfoStore.getState(); + expect(state.callId).toBeNull(); + expect(state.siteInfo).toBeNull(); + expect(state.isLoading).toBe(false); + expect(state.error).toBeNull(); + }); + + it('fetches and stores the site info for a call', async () => { + mockGetCallSiteInfo.mockResolvedValue({ Data: makeSiteInfo('42') } as any); + + await useSiteInfoStore.getState().fetchSiteInfo('42'); + + const state = useSiteInfoStore.getState(); + expect(mockGetCallSiteInfo).toHaveBeenCalledWith('42'); + expect(state.callId).toBe('42'); + expect(state.siteInfo?.Contacts).toHaveLength(1); + expect(state.isLoading).toBe(false); + expect(state.error).toBeNull(); + }); + + it('stores null site info when the server returns no Data', async () => { + mockGetCallSiteInfo.mockResolvedValue({ Data: undefined } as any); + + await useSiteInfoStore.getState().fetchSiteInfo('42'); + + expect(useSiteInfoStore.getState().siteInfo).toBeNull(); + expect(useSiteInfoStore.getState().isLoading).toBe(false); + }); + + it('records the error message and clears loading on failure', async () => { + mockGetCallSiteInfo.mockRejectedValue(new Error('Network Error')); + + await useSiteInfoStore.getState().fetchSiteInfo('42'); + + const state = useSiteInfoStore.getState(); + expect(state.error).toBe('Network Error'); + expect(state.siteInfo).toBeNull(); + expect(state.isLoading).toBe(false); + }); + + it('ignores a stale response after a different call is requested', async () => { + const first = deferred(); + mockGetCallSiteInfo.mockImplementationOnce(() => first.promise); + mockGetCallSiteInfo.mockResolvedValueOnce({ Data: makeSiteInfo('43') } as any); + + const firstFetch = useSiteInfoStore.getState().fetchSiteInfo('42'); + await useSiteInfoStore.getState().fetchSiteInfo('43'); + + first.resolve({ Data: makeSiteInfo('42') }); + await firstFetch; + + const state = useSiteInfoStore.getState(); + expect(state.callId).toBe('43'); + expect(state.siteInfo?.CallId).toBe('43'); + }); + + it('ignores a stale error after a different call is requested', async () => { + const first = deferred(); + mockGetCallSiteInfo.mockImplementationOnce(() => first.promise); + mockGetCallSiteInfo.mockResolvedValueOnce({ Data: makeSiteInfo('43') } as any); + + const firstFetch = useSiteInfoStore.getState().fetchSiteInfo('42'); + await useSiteInfoStore.getState().fetchSiteInfo('43'); + + first.resolve(Promise.reject(new Error('late failure'))); + await firstFetch; + + expect(useSiteInfoStore.getState().error).toBeNull(); + expect(useSiteInfoStore.getState().siteInfo?.CallId).toBe('43'); + }); + + it('reset clears everything', async () => { + mockGetCallSiteInfo.mockResolvedValue({ Data: makeSiteInfo('42') } as any); + await useSiteInfoStore.getState().fetchSiteInfo('42'); + + useSiteInfoStore.getState().reset(); + + const state = useSiteInfoStore.getState(); + expect(state.callId).toBeNull(); + expect(state.siteInfo).toBeNull(); + expect(state.error).toBeNull(); + }); +}); diff --git a/src/stores/calls/site-info-store.ts b/src/stores/calls/site-info-store.ts new file mode 100644 index 00000000..ce7354f0 --- /dev/null +++ b/src/stores/calls/site-info-store.ts @@ -0,0 +1,53 @@ +import { create } from 'zustand'; + +import { getCallSiteInfo } from '@/api/calls/callSiteInfo'; +import { logger } from '@/lib/logging'; +import { type CallSiteInfoData } from '@/models/v4/calls/callSiteInfoResult'; + +interface SiteInfoState { + callId: string | null; + siteInfo: CallSiteInfoData | null; + isLoading: boolean; + error: string | null; + + fetchSiteInfo: (callId: string) => Promise; + reset: () => void; +} + +/** + * Site Info tab state (Contacts plan Phase A): the pre-plans, hazards, alert notes and files of the + * contacts linked to the call being viewed. One call at a time; the tab re-fetches after a step-up so + * REDACTED values are replaced by the revealed ones. + */ +export const useSiteInfoStore = create((set, get) => ({ + callId: null, + siteInfo: null, + isLoading: false, + error: null, + + fetchSiteInfo: async (callId: string) => { + set({ isLoading: true, error: null, callId }); + try { + const result = await getCallSiteInfo(callId); + if (get().callId !== callId) { + return; // stale response — a different call is being viewed now + } + set({ siteInfo: result.Data ?? null, isLoading: false }); + } catch (error) { + if (get().callId !== callId) { + return; + } + logger.error({ + message: 'Failed to fetch call site info', + context: { error, callId }, + }); + set({ + siteInfo: null, + error: error instanceof Error ? error.message : 'Failed to fetch call site info', + isLoading: false, + }); + } + }, + + reset: () => set({ callId: null, siteInfo: null, isLoading: false, error: null }), +})); diff --git a/src/stores/checklists/__tests__/store.test.ts b/src/stores/checklists/__tests__/store.test.ts new file mode 100644 index 00000000..aea09e36 --- /dev/null +++ b/src/stores/checklists/__tests__/store.test.ts @@ -0,0 +1,115 @@ +const mockDisk = new Map(); +jest.mock('expo-crypto', () => ({ randomUUID: () => '11111111-1111-4111-8111-111111111111' })); +jest.mock('@/api/checklists/checklists', () => ({ getChecklistAccess: jest.fn(), getDueChecklists: jest.fn(), getChecklistHistory: jest.fn(), getChecklistRun: jest.fn(), previewChecklistOccurrence: jest.fn(), startChecklistRun: jest.fn(), saveChecklistRun: jest.fn(), uploadChecklistImage: jest.fn() })); +jest.mock('@/lib/checklists/vault', () => ({ + vaultRead: jest.fn(async (s: string, n: string) => { const v = mockDisk.get(`${s}:${n}`); return v ? JSON.parse(v) : null; }), + vaultWrite: jest.fn(async (s: string, n: string, v: unknown) => { mockDisk.set(`${s}:${n}`, JSON.stringify(v)); }), + vaultRemove: jest.fn(async (s: string, n: string) => { mockDisk.delete(`${s}:${n}`); }), +})); +jest.mock('@/lib/storage/app', () => ({ getBaseApiUrl: () => 'server' })); +jest.mock('@/stores/auth/store', () => { const { create } = jest.requireActual('zustand'); return { __esModule: true, default: create(() => ({ userId: 'author' })) }; }); +jest.mock('@/stores/security/store', () => { const { create } = jest.requireActual('zustand'); return { securityStore: create(() => ({ rights: { DepartmentId: '77' } })) }; }); +jest.mock('@/stores/data-protection/store', () => { const { create } = jest.requireActual('zustand'); return { dataProtectionStore: create((_set: unknown, get: () => { grantToken: string | null; stepUpExpiresAt: number | null }) => ({ grantToken: null, stepUpExpiresAt: null, isStepUpActive: () => !!get().grantToken && (get().stepUpExpiresAt ?? 0) > Date.now() })) }; }); +jest.mock('@/stores/offline-queue/store', () => { const { create } = jest.requireActual('zustand'); return { useOfflineQueueStore: create(() => ({ queuedEvents: [], addEvent: jest.fn(), retryEvent: jest.fn(), removeEvent: jest.fn() })) }; }); + +import { AppState } from 'react-native'; +import * as api from '@/api/checklists/checklists'; +import { draft, run } from '@/lib/checklists/__tests__/fixtures'; +import { vaultRead, vaultWrite } from '@/lib/checklists/vault'; +import type { ChecklistDefinition, ChecklistRun } from '@/models/v4/checklists'; +import useAuthStore from '@/stores/auth/store'; +import { checklistScope, flushChecklistDraft, useChecklistsStore } from '@/stores/checklists/store'; +import { dataProtectionStore } from '@/stores/data-protection/store'; +import { useOfflineQueueStore } from '@/stores/offline-queue/store'; +import { securityStore } from '@/stores/security/store'; +const server = jest.mocked(api); +const scope = 'server|77|author'; +const due = { Occurrences: [], Definitions: [], HasMoreOccurrences: false, HasMoreDefinitions: false }; +const definition = (): ChecklistDefinition => ({ Id: 'definition', Revision: 1, VersionId: 'version-1', VersionNumber: 1, Retired: false, IsProtected: false, UpdatedOn: run().UpdatedOn, Form: run().Form, PublishedForm: run().Form, Targets: [run().Target] }); + +beforeEach(() => { + jest.useFakeTimers(); jest.clearAllMocks(); mockDisk.clear(); Object.defineProperty(AppState, 'currentState', { value: 'active', configurable: true }); + useAuthStore.setState({ userId: 'author' }); securityStore.setState({ rights: { ...securityStore.getState().rights!, DepartmentId: '77' } }); + dataProtectionStore.setState({ grantToken: null, stepUpExpiresAt: null }); + useChecklistsStore.setState({ access: null, scope: null, active: null, due: null, history: [], draftIds: [], error: null, locked: false }); + server.getChecklistAccess.mockResolvedValue({ DepartmentId: 77, UserId: 'author', CanManage: false, Enabled: true, IsProtected: false }); + server.getDueChecklists.mockResolvedValue(due); server.getChecklistRun.mockResolvedValue(run()); server.startChecklistRun.mockResolvedValue(run()); + server.saveChecklistRun.mockImplementation(async (_id, input, submit) => ({ ...run(), Revision: input.Revision + 1, Input: { ...input, Revision: input.Revision + 1 }, State: submit ? 2 : 0, CanEdit: !submit })); +}); +afterEach(() => { dataProtectionStore.setState({ grantToken: null, stepUpExpiresAt: null }); jest.useRealTimers(); }); + +it('keeps values in the dedicated vault and only references in the ordinary offline queue', async () => { + await useChecklistsStore.getState().load('12'); expect(server.getDueChecklists).toHaveBeenCalledWith(0, '12'); + await useChecklistsStore.getState().startDefinition(definition(), run().Target); + await useChecklistsStore.getState().update({ Revision: 0, Answers: [], Note: 'SYNTHETIC PHI' }); + await useChecklistsStore.getState().queue(true); + expect(useOfflineQueueStore.getState().addEvent).toHaveBeenCalledWith('checklist_completion', { scope, id: run().Id }, 5); + expect(JSON.stringify(jest.mocked(useOfflineQueueStore.getState().addEvent).mock.calls)).not.toContain('SYNTHETIC PHI'); + useChecklistsStore.getState().close(); await useChecklistsStore.getState().openDraft(run().Id); + expect(useChecklistsStore.getState().active?.input.Note).toBe('SYNTHETIC PHI'); +}); +it('previews occurrences without starting server work and reopens prepared data offline', async () => { + await useChecklistsStore.getState().load(); + server.previewChecklistOccurrence.mockResolvedValue({ ...run(), Revision: 0, IsPreview: true, OccurrenceId: 'occurrence', Input: { Revision: 0, Answers: [] } }); + await useChecklistsStore.getState().openOccurrence('occurrence'); useChecklistsStore.getState().close(); + server.previewChecklistOccurrence.mockRejectedValue(new Error('network')); + await useChecklistsStore.getState().openOccurrence('occurrence'); + expect(useChecklistsStore.getState().active?.start.OccurrenceId).toBe('occurrence'); expect(server.startChecklistRun).not.toHaveBeenCalled(); +}); +it('retains queued content on a stale revision and requires explicit replacement', async () => { + await useChecklistsStore.getState().load(); await vaultWrite(scope, `draft:${run().Id}`, draft()); await vaultWrite(scope, 'index', [run().Id]); + server.getChecklistRun.mockResolvedValue({ ...run(), Revision: 8 }); server.saveChecklistRun.mockRejectedValue({ response: { status: 409, data: { Title: 'PRIVATE' } } }); + await expect(flushChecklistDraft(scope, run().Id)).rejects.toThrow('checklist_conflict'); + const saved = await vaultRead>(scope, `draft:${run().Id}`); + expect(saved?.input.Revision).toBe(1); expect(saved?.queued).toBe(true); + await useChecklistsStore.getState().openDraft(run().Id); await useChecklistsStore.getState().editLocal(); + expect(useChecklistsStore.getState().active?.queued).toBe(false); +}); +it('removes acknowledged values, retains a bounded receipt and tolerates a queue replay after restart', async () => { + await useChecklistsStore.getState().load(); await vaultWrite(scope, `draft:${run().Id}`, draft()); await vaultWrite(scope, 'index', [run().Id]); + await flushChecklistDraft(scope, run().Id); + expect(await vaultRead(scope, `draft:${run().Id}`)).toBeNull(); expect(await vaultRead(scope, 'index')).toEqual([]); + await flushChecklistDraft(scope, run().Id); expect(server.saveChecklistRun).toHaveBeenCalledTimes(1); +}); +it('checks fresh flag and membership access before decrypting or sending queued work', async () => { + await useChecklistsStore.getState().load(); await vaultWrite(scope, `draft:${run().Id}`, draft()); jest.mocked(vaultRead).mockClear(); + server.getChecklistAccess.mockResolvedValue({ DepartmentId: 77, UserId: 'author', CanManage: false, Enabled: false, IsProtected: false }); + await expect(flushChecklistDraft(scope, run().Id)).rejects.toThrow('checklist_denied'); + expect(vaultRead).not.toHaveBeenCalled(); expect(server.saveChecklistRun).not.toHaveBeenCalled(); expect(mockDisk.has(`${scope}:draft:${run().Id}`)).toBe(true); +}); +it('conceals at absolute ADP expiry without deleting unsent data', async () => { + server.getChecklistAccess.mockResolvedValue({ DepartmentId: 77, UserId: 'author', CanManage: false, Enabled: true, IsProtected: true }); + dataProtectionStore.setState({ grantToken: 'synthetic-grant', stepUpExpiresAt: Date.now() + 1000 }); + await useChecklistsStore.getState().load(); await useChecklistsStore.getState().startDefinition(definition(), run().Target); + jest.advanceTimersByTime(1001); + expect(useChecklistsStore.getState().active).toBeNull(); expect(useChecklistsStore.getState().due).toBeNull(); + await expect(useChecklistsStore.getState().openDraft(run().Id)).rejects.toThrow('locked'); expect(mockDisk.has(`${scope}:draft:${run().Id}`)).toBe(true); +}); +it('rejects another identity and discards late due responses after an identity switch', async () => { + let release!: (value: typeof due) => void; server.getDueChecklists.mockReturnValue(new Promise(resolve => { release = resolve; })); + const loading = useChecklistsStore.getState().load(); await Promise.resolve(); await Promise.resolve(); + useAuthStore.setState({ userId: 'other' }); release(due); await loading; + expect(checklistScope()).toBe('server|77|other'); expect(useChecklistsStore.getState().due).toBeNull(); + await expect(flushChecklistDraft(scope, run().Id)).rejects.toThrow('checklist_denied'); +}); +it('does not persist read-only historical runs as editable drafts', async () => { + server.getChecklistRun.mockResolvedValue({ ...run(), State: 2, CanEdit: false }); + await useChecklistsStore.getState().openServer(run().Id); + expect(useChecklistsStore.getState().active?.run.State).toBe(2); expect(await vaultRead(scope, 'index')).toBeNull(); +}); +it('rejects a late server response when the grant expires during upload', async () => { + server.getChecklistAccess.mockResolvedValue({ DepartmentId: 77, UserId: 'author', CanManage: false, Enabled: true, IsProtected: true }); + dataProtectionStore.setState({ grantToken: 'synthetic-grant', stepUpExpiresAt: Date.now() + 1000 }); await useChecklistsStore.getState().load(); + const local = draft(); local.images = [{ id: 'image', itemId: 'first', name: 'evidence.jpg', contentType: 'image/jpeg', base64: 'synthetic' }]; await vaultWrite(scope, `draft:${local.id}`, local); + server.uploadChecklistImage.mockImplementation(async () => { jest.advanceTimersByTime(1001); return { ...run(), Revision: 2 }; }); + await expect(flushChecklistDraft(scope, local.id)).rejects.toThrow('checklist_locked'); + expect(server.saveChecklistRun).not.toHaveBeenCalled(); expect((await vaultRead>(scope, `draft:${local.id}`))?.images).toHaveLength(1); +}); + +it('discards the old queued draft when the user explicitly adopts a submitted server version', async () => { + await useChecklistsStore.getState().load(); await vaultWrite(scope, `draft:${run().Id}`, draft()); await vaultWrite(scope, 'index', [run().Id]); + await useChecklistsStore.getState().openDraft(run().Id); server.getChecklistRun.mockResolvedValue({ ...run(), State: 2, CanEdit: false }); + await useChecklistsStore.getState().useServerVersion(); + expect(useChecklistsStore.getState().active?.queued).toBe(false); expect(await vaultRead(scope, `draft:${run().Id}`)).toBeNull(); + await flushChecklistDraft(scope, run().Id); expect(server.saveChecklistRun).not.toHaveBeenCalled(); +}); diff --git a/src/stores/checklists/store.ts b/src/stores/checklists/store.ts new file mode 100644 index 00000000..c345eacb --- /dev/null +++ b/src/stores/checklists/store.ts @@ -0,0 +1,378 @@ +import { randomUUID } from 'expo-crypto'; +import { AppState } from 'react-native'; +import { create } from 'zustand'; + +import { getChecklistAccess, getChecklistHistory, getChecklistRun, getDueChecklists, previewChecklistOccurrence, saveChecklistRun, startChecklistRun, uploadChecklistImage } from '@/api/checklists/checklists'; +import { checklistError, syncChecklistDraft } from '@/lib/checklists/sync'; +import { vaultRead, vaultRemove, vaultWrite } from '@/lib/checklists/vault'; +import { getBaseApiUrl } from '@/lib/storage/app'; +import { QueuedEventType } from '@/models/offline-queue/queued-event'; +import type { ChecklistAccess, ChecklistDefinition, ChecklistDraft, ChecklistDuePage, ChecklistHistory, ChecklistImage, ChecklistInput, ChecklistRun, ChecklistTarget } from '@/models/v4/checklists'; +import useAuthStore from '@/stores/auth/store'; +import { dataProtectionStore } from '@/stores/data-protection/store'; +import { useOfflineQueueStore } from '@/stores/offline-queue/store'; +import { securityStore } from '@/stores/security/store'; + +export const checklistScope = (): string | null => { + const user = useAuthStore.getState().userId; + const department = securityStore.getState().rights?.DepartmentId; + return user && department ? `${getBaseApiUrl()}|${department}|${user}` : null; +}; +interface ChecklistState { + access: ChecklistAccess | null; + scope: string | null; + due: ChecklistDuePage | null; + active: ChecklistDraft | null; + draftIds: string[]; + history: ChecklistHistory[]; + hasMoreHistory: boolean; + busy: boolean; + locked: boolean; + error: string | null; + load: (unitId?: string, page?: number) => Promise; + loadHistory: (unitId?: string, page?: number) => Promise; + startDefinition: (definition: ChecklistDefinition, target: ChecklistTarget) => Promise; + openOccurrence: (id: string) => Promise; + openDraft: (id: string) => Promise; + openServer: (id: string) => Promise; + update: (input: ChecklistInput) => Promise; + addImage: (image: ChecklistImage) => Promise; + removeImage: (id: string) => Promise; + queue: (submit: boolean) => Promise; + retry: (id: string) => Promise; + useServerVersion: () => Promise; + editLocal: () => Promise; + prepareOffline: () => Promise; + close: () => void; + conceal: () => void; +} +let writes = Promise.resolve(); +const flights = new Map>(); +const serializeWrite = (job: () => Promise) => { + const next = writes.catch(() => undefined).then(job); + writes = next; + return next; +}; +const current = (scope: string): boolean => checklistScope() === scope && useChecklistsStore.getState().scope === scope; +const assertOpen = (scope: string) => { + const state = useChecklistsStore.getState(); + if (!current(scope) || !state.access?.Enabled) throw new Error('denied'); + if (AppState.currentState !== 'active' || (state.access.IsProtected && !dataProtectionStore.getState().isStepUpActive())) throw new Error('locked'); +}; +const persistDraft = (draft: ChecklistDraft): Promise => { + const copy: ChecklistDraft = JSON.parse(JSON.stringify(draft)); + return serializeWrite(async () => { + const ids = (await vaultRead(copy.scope, 'index')) ?? []; + if (!ids.includes(copy.id) && ids.length >= 50) throw new Error('storage_full'); + await vaultWrite(copy.scope, `draft:${copy.id}`, copy); + if (!ids.includes(copy.id)) await vaultWrite(copy.scope, 'index', [...ids, copy.id]); + }); +}; +// Keep a bounded, value-free receipt before erasing acknowledged content. A queue retry after +// a process exit can consume this receipt without recreating the completed run. +const retireDraft = (draft: ChecklistDraft) => + serializeWrite(async () => { + const done = (await vaultRead(draft.scope, 'acknowledged')) ?? []; + await vaultWrite(draft.scope, 'acknowledged', [...done.filter((id) => id !== draft.id), draft.id].slice(-256)); + const ids = (await vaultRead(draft.scope, 'index')) ?? []; + await vaultWrite( + draft.scope, + 'index', + ids.filter((id) => id !== draft.id) + ); + await vaultRemove(draft.scope, `draft:${draft.id}`); + if (draft.run.OccurrenceId) await vaultRemove(draft.scope, `preview:${draft.run.OccurrenceId}`); + if (current(draft.scope)) useChecklistsStore.setState((state) => ({ draftIds: state.draftIds.filter((id) => id !== draft.id) })); + }); +const stage = async (draft: ChecklistDraft) => { + assertOpen(draft.scope); + if (draft.run.CanEdit) await persistDraft(draft); + assertOpen(draft.scope); + const ids = useChecklistsStore.getState().draftIds; + useChecklistsStore.setState({ active: draft, draftIds: !draft.run.CanEdit || ids.includes(draft.id) ? ids : [...ids, draft.id], error: null }); +}; +const readAccess = async (): Promise => { + const scope = checklistScope(); + if (!scope) throw new Error('denied'); + if (useChecklistsStore.getState().scope !== scope) useChecklistsStore.getState().conceal(); + const access = await getChecklistAccess(); + if (scope !== checklistScope() || access.UserId !== useAuthStore.getState().userId || access.DepartmentId !== Number(securityStore.getState().rights?.DepartmentId)) throw new Error('denied'); + useChecklistsStore.setState({ access, scope, locked: access.IsProtected && !dataProtectionStore.getState().isStepUpActive() }); + assertOpen(scope); + return scope; +}; +const pending = (run: ChecklistRun, scope: string, occurrence = false): ChecklistDraft => ({ + id: run.Id, + scope, + run, + start: occurrence ? { CompletionId: run.Id, OccurrenceId: run.OccurrenceId } : { CompletionId: run.Id, DefinitionId: run.DefinitionId, VersionId: run.VersionId, TargetId: run.Target.Id }, + input: run.Input, + images: [], + queued: false, + submit: false, +}); +const enqueue = (scope: string, id: string) => { + const queue = useOfflineQueueStore.getState(); + const existing = queue.queuedEvents.find((e) => e.type === QueuedEventType.CHECKLIST_COMPLETION && e.data.scope === scope && e.data.id === id); + if (existing) queue.retryEvent(existing.id); + else queue.addEvent(QueuedEventType.CHECKLIST_COMPLETION, { scope, id }, 5); +}; +export const useChecklistsStore = create()((set, get) => ({ + access: null, + scope: null, + due: null, + active: null, + draftIds: [], + history: [], + hasMoreHistory: false, + busy: false, + locked: false, + error: null, + load: async (unitId, page = 0) => { + set({ busy: true, error: null }); + try { + const scope = await readAccess(); + const due = await getDueChecklists(page, unitId); + assertOpen(scope); + await writes.catch(() => undefined); + const draftIds = (await vaultRead(scope, 'index')) ?? []; + await vaultWrite(scope, `due:${unitId ?? 'self'}:${page}`, due); + assertOpen(scope); + set({ due, draftIds, locked: false }); + } catch (error) { + const code = checklistError(error); + // A transient connection failure may reuse a previously authorized, encrypted working set. + if (code === 'retry' && get().scope) { + try { + const scope = get().scope!; + assertOpen(scope); + const due = await vaultRead(scope, `due:${unitId ?? 'self'}:${page}`); + const draftIds = (await vaultRead(scope, 'index')) ?? []; + assertOpen(scope); + set({ due, draftIds, error: 'offline' }); + } catch { + get().conceal(); + set({ error: 'locked' }); + } + } else { + get().conceal(); + set({ error: code }); + } + } finally { + set({ busy: false }); + } + }, + loadHistory: async (unitId, page = 0) => { + try { + const scope = await readAccess(); + const result = await getChecklistHistory(page, unitId); + assertOpen(scope); + set({ history: result.Data, hasMoreHistory: result.HasMore }); + } catch (error) { + set({ error: checklistError(error) }); + } + }, + startDefinition: async (definition, target) => { + const scope = get().scope!; + assertOpen(scope); + const form = definition.PublishedForm; + const id = randomUUID(); + const run: ChecklistRun = { + Id: id, + DefinitionId: definition.Id, + OccurrenceId: null, + VersionId: definition.VersionId, + VersionNumber: definition.VersionNumber, + Revision: 0, + State: 0, + IsProtected: !!get().access?.IsProtected, + IsPreview: true, + CanEdit: true, + CreatedBy: useAuthStore.getState().userId!, + UpdatedOn: new Date().toISOString(), + Form: form, + Target: target, + Input: { Revision: 0, Answers: [] }, + Files: [], + }; + await stage(pending(run, scope)); + }, + openOccurrence: async (id) => { + const scope = get().scope!; + assertOpen(scope); + let run: ChecklistRun; + try { + run = await previewChecklistOccurrence(id); + assertOpen(scope); + await vaultWrite(scope, `preview:${id}`, run); + } catch (error) { + if (checklistError(error) !== 'retry') throw error; + run = (await vaultRead(scope, `preview:${id}`))!; + if (!run) throw error; + } + assertOpen(scope); + const saved = await vaultRead(scope, `draft:${run.Id}`); + if (saved) { + assertOpen(scope); + set({ active: saved }); + } else await stage(pending(run, scope, true)); + }, + openDraft: async (id) => { + const scope = get().scope!; + assertOpen(scope); + await writes.catch(() => undefined); + const draft = await vaultRead(scope, `draft:${id}`); + assertOpen(scope); + if (draft?.scope !== scope) throw new Error('denied'); + set({ active: draft }); + }, + openServer: async (id) => { + const scope = await readAccess(); + const run = await getChecklistRun(id); + assertOpen(scope); + await stage(pending(run, scope)); + }, + update: async (input) => { + const draft = get().active; + if (!draft || draft.queued || !draft.run.CanEdit) throw new Error('denied'); + assertOpen(draft.scope); + const changed = { ...draft, input }; + set({ active: changed }); + await persistDraft(changed); + }, + addImage: async (image) => { + const draft = get().active; + if (!draft || draft.queued || !draft.run.CanEdit) throw new Error('denied'); + if (image.base64.length > 14 * 1024 * 1024 || draft.images.filter((i) => i.itemId === image.itemId).length + draft.run.Files.filter((i) => i.ItemId === image.itemId).length >= 3) throw new Error('storage_full'); + const answers = [...draft.input.Answers.filter((a) => a.ItemId !== image.itemId), { ...(draft.input.Answers.find((a) => a.ItemId === image.itemId) ?? {}), ItemId: image.itemId, Status: 1 }]; + await stage({ ...draft, images: [...draft.images, image], input: { ...draft.input, Answers: answers } }); + }, + removeImage: async (id) => { + const draft = get().active; + if (!draft || draft.queued || !draft.run.CanEdit) throw new Error('denied'); + await stage({ ...draft, images: draft.images.filter((image) => image.id !== id) }); + }, + queue: async (submit) => { + const draft = get().active; + if (!draft || !draft.run.CanEdit) throw new Error('denied'); + await writes; + const queued = { ...draft, queued: true, submit, input: { ...draft.input, ClientCompletedOn: submit ? (draft.input.ClientCompletedOn ?? new Date().toISOString()) : draft.input.ClientCompletedOn } }; + await stage(queued); + enqueue(draft.scope, draft.id); + }, + retry: async (id) => { + const scope = await readAccess(); + await get().openDraft(id); + const draft = get().active!; + await stage({ ...draft, queued: true, error: undefined }); + enqueue(scope, id); + }, + prepareOffline: async () => { + const scope = await readAccess(); + for (const row of get().due?.Occurrences ?? []) { + const run = await previewChecklistOccurrence(row.Id); + assertOpen(scope); + await vaultWrite(scope, `preview:${row.Id}`, run); + } + }, + editLocal: async () => { + const draft = get().active; + if (!draft) return; + assertOpen(draft.scope); + if (flights.has(`${draft.scope}:${draft.id}`)) throw new Error('retry'); + const queue = useOfflineQueueStore.getState(); + for (const event of queue.queuedEvents) if (event.type === QueuedEventType.CHECKLIST_COMPLETION && event.data.scope === draft.scope && event.data.id === draft.id) queue.removeEvent(event.id); + await stage({ ...draft, queued: false }); + }, + useServerVersion: async () => { + const draft = get().active; + if (!draft) return; + if (flights.has(`${draft.scope}:${draft.id}`)) throw new Error('retry'); + const scope = await readAccess(); + const run = await getChecklistRun(draft.id); + assertOpen(scope); + if (flights.has(`${scope}:${draft.id}`)) throw new Error('retry'); + if (run.State > 0) await retireDraft(draft); + await stage(pending(run, scope)); + const queue = useOfflineQueueStore.getState(); + for (const event of queue.queuedEvents) if (event.type === QueuedEventType.CHECKLIST_COMPLETION && event.data.scope === scope && event.data.id === draft.id) queue.removeEvent(event.id); + }, + close: () => set({ active: null }), + conceal: () => { + set({ due: null, active: null, history: [], draftIds: [], locked: true }); + }, +})); + +export const flushChecklistDraft = (scope: string, id: string): Promise => { + const key = `${scope}:${id}`; + const existing = flights.get(key); + if (existing) return existing; + const work = (async () => { + try { + if (scope !== checklistScope()) throw new Error('denied'); + const verified = await readAccess(); + if (verified !== scope) throw new Error('denied'); + await writes; + const draft = await vaultRead(scope, `draft:${id}`); + assertOpen(scope); + if (!draft) { + const done = (await vaultRead(scope, 'acknowledged')) ?? []; + assertOpen(scope); + if (done.includes(id)) return; + throw new Error('denied'); + } + if (draft.scope !== scope) throw new Error('denied'); + if (!draft.queued) { + if (draft.run.State > 0) await retireDraft(draft); + return; + } + const receipt = await syncChecklistDraft(draft, { + check: () => assertOpen(scope), + start: (d) => (d.input.Revision === 0 ? startChecklistRun(d.start) : getChecklistRun(d.id)), + upload: (d, index) => uploadChecklistImage(d.id, d.input.Revision, d.images[index]), + save: (d) => saveChecklistRun(d.id, d.input, d.submit), + persist: persistDraft, + }); + if (receipt.run.State > 0) await retireDraft(receipt); + assertOpen(scope); + if (current(scope) && useChecklistsStore.getState().active?.id === id) useChecklistsStore.setState({ active: receipt, error: null }); + } catch (error) { + const code = checklistError(error); + if (current(scope)) { + if (code === 'locked' || code === 'denied') useChecklistsStore.getState().conceal(); + useChecklistsStore.setState({ error: code }); + } + // The ordinary queue logs this Error, so only a fixed, value-free code leaves this boundary. + throw new Error(`checklist_${code}`); + } + })(); + flights.set(key, work); + work.finally(() => flights.delete(key)).catch(() => undefined); + return work; +}; + +let expiry: ReturnType | null = null; +const concealOnIdentityChange = () => { + const state = useChecklistsStore.getState(); + if (state.scope && state.scope !== checklistScope()) { + state.conceal(); + useChecklistsStore.setState({ scope: null, access: null }); + } +}; +useAuthStore.subscribe(concealOnIdentityChange); +securityStore.subscribe(concealOnIdentityChange); +dataProtectionStore.subscribe((state) => { + if (expiry) clearTimeout(expiry); + if (!state.isStepUpActive()) { + if (useChecklistsStore.getState().access?.IsProtected) useChecklistsStore.getState().conceal(); + } else if (state.stepUpExpiresAt) + expiry = setTimeout( + () => { + if (useChecklistsStore.getState().access?.IsProtected) useChecklistsStore.getState().conceal(); + }, + Math.max(0, state.stepUpExpiresAt - Date.now()) + ); +}); +AppState.addEventListener('change', (state) => { + if (state !== 'active') useChecklistsStore.getState().conceal(); +}); diff --git a/src/stores/contacts/preplan-store.ts b/src/stores/contacts/preplan-store.ts new file mode 100644 index 00000000..a8cac0c8 --- /dev/null +++ b/src/stores/contacts/preplan-store.ts @@ -0,0 +1,84 @@ +import { create } from 'zustand'; + +import { getContactFiles } from '@/api/contacts/contactFiles'; +import { getContactPreplan } from '@/api/contacts/contactPreplans'; +import { logger } from '@/lib/logging'; +import { type ContactFileResultData } from '@/models/v4/contactFiles/contactFilesResult'; +import { type ContactPreplanData } from '@/models/v4/contacts/contactPreplanResult'; + +interface ContactPreplanState { + /** Pre-plan per contact; an explicit null means "fetched, the contact has none". */ + preplans: Record; + files: Record; + loadingPreplan: Record; + loadingFiles: Record; + error: string | null; + + fetchPreplan: (contactId: string, force?: boolean) => Promise; + fetchFiles: (contactId: string, force?: boolean) => Promise; + invalidate: (contactId: string) => void; + reset: () => void; +} + +/** + * Pre-plan and site-file cache for the contact details sheet (Contacts plan Phase A). Cached per contact + * for the life of the sheet; `force` re-fetches after a step-up so REDACTED values are replaced. + */ +export const useContactPreplanStore = create((set, get) => ({ + preplans: {}, + files: {}, + loadingPreplan: {}, + loadingFiles: {}, + error: null, + + fetchPreplan: async (contactId: string, force = false) => { + if (!contactId) return; + if (!force && Object.prototype.hasOwnProperty.call(get().preplans, contactId)) return; + + set((state) => ({ loadingPreplan: { ...state.loadingPreplan, [contactId]: true }, error: null })); + try { + const result = await getContactPreplan(contactId); + set((state) => ({ + preplans: { ...state.preplans, [contactId]: result.Data ?? null }, + loadingPreplan: { ...state.loadingPreplan, [contactId]: false }, + })); + } catch (error) { + logger.error({ message: 'Failed to fetch contact pre-plan', context: { error, contactId } }); + set((state) => ({ + loadingPreplan: { ...state.loadingPreplan, [contactId]: false }, + error: error instanceof Error ? error.message : 'Failed to fetch contact pre-plan', + })); + } + }, + + fetchFiles: async (contactId: string, force = false) => { + if (!contactId) return; + if (!force && Object.prototype.hasOwnProperty.call(get().files, contactId)) return; + + set((state) => ({ loadingFiles: { ...state.loadingFiles, [contactId]: true }, error: null })); + try { + const result = await getContactFiles(contactId, false); + set((state) => ({ + files: { ...state.files, [contactId]: result.Data ?? [] }, + loadingFiles: { ...state.loadingFiles, [contactId]: false }, + })); + } catch (error) { + logger.error({ message: 'Failed to fetch contact files', context: { error, contactId } }); + set((state) => ({ + loadingFiles: { ...state.loadingFiles, [contactId]: false }, + error: error instanceof Error ? error.message : 'Failed to fetch contact files', + })); + } + }, + + invalidate: (contactId: string) => + set((state) => { + const preplans = { ...state.preplans }; + const files = { ...state.files }; + delete preplans[contactId]; + delete files[contactId]; + return { preplans, files }; + }), + + reset: () => set({ preplans: {}, files: {}, loadingPreplan: {}, loadingFiles: {}, error: null }), +})); diff --git a/src/stores/contacts/store.ts b/src/stores/contacts/store.ts index 024279d9..cf6ec8ed 100644 --- a/src/stores/contacts/store.ts +++ b/src/stores/contacts/store.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { getContactNotes } from '@/api/contacts/contactNotes'; -import { getAllContacts } from '@/api/contacts/contacts'; +import { getAllContacts, getContact } from '@/api/contacts/contacts'; import { type ContactNoteResultData } from '@/models/v4/contacts/contactNoteResultData'; import { type ContactResultData } from '@/models/v4/contacts/contactResultData'; @@ -10,13 +10,18 @@ interface ContactsState { contactNotes: Record; searchQuery: string; selectedContactId: string | null; + // Full record from GetContactById — the GetAllContacts list payload is slim (no address, custom fields + // or category), so the details sheet needs this to show every field + selectedContactDetails: ContactResultData | null; isDetailsOpen: boolean; isLoading: boolean; + isDetailsLoading: boolean; isNotesLoading: boolean; error: string | null; // Actions fetchContacts: (forceRefresh?: boolean) => Promise; - fetchContactNotes: (contactId: string) => Promise; + fetchContactDetails: (contactId: string) => Promise; + fetchContactNotes: (contactId: string, force?: boolean) => Promise; setSearchQuery: (query: string) => void; selectContact: (id: string) => void; closeDetails: () => void; @@ -27,8 +32,10 @@ export const useContactsStore = create((set, get) => ({ contactNotes: {}, searchQuery: '', selectedContactId: null, + selectedContactDetails: null, isDetailsOpen: false, isLoading: false, + isDetailsLoading: false, isNotesLoading: false, error: null, @@ -42,11 +49,12 @@ export const useContactsStore = create((set, get) => ({ } }, - fetchContactNotes: async (contactId: string) => { + fetchContactNotes: async (contactId: string, force = false) => { const { contactNotes } = get(); - // Don't fetch if we already have notes for this contact - if (contactNotes[contactId]) { + // Don't fetch if we already have notes for this contact, unless the sheet asks for a fresh read + // (opening a contact, or after protected data was unlocked, when the cached notes may be redacted). + if (contactNotes[contactId] && !force) { return; } @@ -68,9 +76,29 @@ export const useContactsStore = create((set, get) => ({ } }, + fetchContactDetails: async (contactId: string) => { + set({ isDetailsLoading: true, selectedContactDetails: null }); + try { + const response = await getContact(contactId); + if (get().selectedContactId !== contactId) { + return; // stale response — a newer contact was selected meanwhile + } + set({ selectedContactDetails: response.Data || null, isDetailsLoading: false }); + } catch { + if (get().selectedContactId !== contactId) { + return; + } + // The sheet falls back to the slim list record already in the store + set({ isDetailsLoading: false, selectedContactDetails: null }); + } + }, + setSearchQuery: (query) => set({ searchQuery: query }), - selectContact: (id) => set({ selectedContactId: id, isDetailsOpen: true }), + selectContact: (id) => { + set({ selectedContactId: id, isDetailsOpen: true }); + void get().fetchContactDetails(id); + }, - closeDetails: () => set({ isDetailsOpen: false }), + closeDetails: () => set({ isDetailsOpen: false, selectedContactDetails: null }), })); diff --git a/src/stores/feature-flags/store.ts b/src/stores/feature-flags/store.ts index 0e35e3c3..f35e6469 100644 --- a/src/stores/feature-flags/store.ts +++ b/src/stores/feature-flags/store.ts @@ -11,6 +11,16 @@ import { securityStore } from '../security/store'; // Well-known feature flag keys. Keep values in sync with Resgrid.Model.FeatureFlagKeys. export const FeatureFlagKeys = { ChatSystem: 'Chat.System', + ChecklistsSystem: 'Checklists.System', + RecordsSystem: 'Records.System', + /** Field Records in this app. Depends on Records.System and is seeded off (RMS plan RMS-1D). */ + RecordsFieldUnit: 'Records.Field.Unit', + /** Deployments, daily time reports and resource usage (Workforce & Business Operations plan, Phase C). */ + Deployments: 'Operations.Deployments', + /** CAL OES MARS cost recovery (F-42) on top of Deployments. */ + CalOesMars: 'CostRecovery.CalOesMars', + /** Internal field costing and usage capture (Phase E). */ + WorkforceInternalCosting: 'Workforce.InternalCosting', } as const; export type FeatureFlagKey = (typeof FeatureFlagKeys)[keyof typeof FeatureFlagKeys]; @@ -131,3 +141,33 @@ export const useFeatureFlagStatus = (key: string): FeatureFlagStatus => }); export const useChatSystemStatus = (): FeatureFlagStatus => useFeatureFlagStatus(FeatureFlagKeys.ChatSystem); + +// Field Records is gated by the parent Records.System flag and this app's own child flag; both must +// be on. Unknown resolves fail-closed, so the surface stays hidden until the server confirms it. +export const useIsRecordsFieldEnabled = () => { + // Both hooks run unconditionally: `&&` would skip the second one whenever the parent is off, + // and the hook count changing between renders is a rules-of-hooks violation. + const system = useFeatureFlag(FeatureFlagKeys.RecordsSystem); + const field = useFeatureFlag(FeatureFlagKeys.RecordsFieldUnit); + return system && field; +}; + +export const useRecordsFieldStatus = (): FeatureFlagStatus => { + const parent = useFeatureFlagStatus(FeatureFlagKeys.RecordsSystem); + const child = useFeatureFlagStatus(FeatureFlagKeys.RecordsFieldUnit); + if (parent === 'disabled' || child === 'disabled') { + return 'disabled'; + } + if (parent === 'unknown' || child === 'unknown') { + return 'unknown'; + } + return 'enabled'; +}; + +export const useIsChecklistsEnabled = () => useFeatureFlag(FeatureFlagKeys.ChecklistsSystem); + +// Deployments (DTR / usage / F-42) are gated by Operations.Deployments alone; the cost-recovery and +// costing add-ons are answered per department by the server's access calls, not by a flag here. +export const useIsDeploymentsEnabled = () => useFeatureFlag(FeatureFlagKeys.Deployments); + +export const useDeploymentsStatus = (): FeatureFlagStatus => useFeatureFlagStatus(FeatureFlagKeys.Deployments); diff --git a/src/stores/inventory/__tests__/store.test.ts b/src/stores/inventory/__tests__/store.test.ts new file mode 100644 index 00000000..6040dca8 --- /dev/null +++ b/src/stores/inventory/__tests__/store.test.ts @@ -0,0 +1,85 @@ +let mockUuid = 0; +jest.mock('expo-crypto', () => ({ randomUUID: () => `00000000-0000-4000-8000-00000000000${++mockUuid}` })); +jest.mock('@/api/inventory/inventory', () => ({ + getInventoryAccess: jest.fn(), + getUnitEquipment: jest.fn(), + getCounts: jest.fn(), + getCount: jest.fn(), + startCount: jest.fn(), + saveCount: jest.fn(), + completeCount: jest.fn(), + cancelCount: jest.fn(), +})); +jest.mock('@/stores/auth/store', () => { + const { create } = jest.requireActual('zustand'); + return { __esModule: true, default: create(() => ({ userId: 'me' })) }; +}); +jest.mock('@/stores/security/store', () => { + const { create } = jest.requireActual('zustand'); + return { securityStore: create(() => ({ rights: { DepartmentId: '77' } })) }; +}); + +import * as api from '@/api/inventory/inventory'; +import { useInventoryStore } from '@/stores/inventory/store'; + +const server = jest.mocked(api); +const detail = (revision: number, counted: (number | null)[] = [null, null], status = 0) => + ({ + Count: { Id: 'c-1', Revision: revision, Status: status, LocationId: 'u', Content: '{"Name":"Engine 41 check"}' }, + Lines: counted.map((value, index) => ({ Id: `l-${index}`, Revision: 0, CountId: 'c-1', ItemId: `i-${index}`, LocationId: 'u', ExpectedQuantity: 2, CountedQuantity: value })), + }) as never; + +beforeEach(() => { + jest.clearAllMocks(); + mockUuid = 0; + useInventoryStore.setState({ identity: null, unitId: null, access: null, equipment: [], counts: [], count: null, observed: {}, completionId: null, error: null, awaitingWitness: false }); + server.getInventoryAccess.mockResolvedValue({ Enabled: true, Migrated: true, CanCount: true, CanIssue: false, CanTransfer: false, UnitLocations: [{ Id: 'u', Name: 'Engine 41', LocationType: 2, IsRoot: true }, { Id: 'l1', Name: 'L1', LocationType: 0, IsRoot: false }] }); + server.getUnitEquipment.mockResolvedValue([]); + server.getCounts.mockResolvedValue({ Items: [], HasMore: false }); +}); + +it('loads the unit, lists counts only for a counter and starts a count at the chosen location', async () => { + await useInventoryStore.getState().load(41); + expect(server.getInventoryAccess).toHaveBeenCalledWith(41); + expect(server.getCounts).toHaveBeenCalledWith('u'); + expect(server.getCounts).toHaveBeenCalledWith('l1'); + + server.startCount.mockResolvedValue(detail(1)); + expect(await useInventoryStore.getState().start('u', 'Engine 41 check')).toBe('c-1'); + expect(server.startCount).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000001', 'u', 'Engine 41 check'); + + server.getInventoryAccess.mockResolvedValue({ Enabled: true, Migrated: true, CanCount: false, CanIssue: false, CanTransfer: false, UnitLocations: [{ Id: 'u', Name: 'E', LocationType: 2, IsRoot: true }] }); + server.getCounts.mockClear(); + await useInventoryStore.getState().load(41); + expect(server.getCounts).not.toHaveBeenCalled(); +}); + +it('saves only changed values, then completes with a stable request id across a retry', async () => { + server.getCount.mockResolvedValue(detail(1)); + await useInventoryStore.getState().open('c-1'); + useInventoryStore.getState().setObserved('l-0', 2); + useInventoryStore.getState().setObserved('l-1', 1); + server.saveCount.mockResolvedValue(detail(2, [2, 1])); + server.completeCount.mockRejectedValueOnce({ response: { status: 500 } }); + expect(await useInventoryStore.getState().complete()).toBe(false); + expect(server.saveCount).toHaveBeenCalledWith('c-1', 1, [ + { Id: 'l-0', Quantity: 2 }, + { Id: 'l-1', Quantity: 1 }, + ]); + const firstRequest = server.completeCount.mock.calls[0][2]; + + server.completeCount.mockResolvedValue({ AwaitingWitness: false }); + server.getCount.mockResolvedValue(detail(3, [2, 1], 2)); + expect(await useInventoryStore.getState().complete()).toBe(true); + expect(server.saveCount).toHaveBeenCalledTimes(1); + expect(server.completeCount).toHaveBeenLastCalledWith('c-1', 2, firstRequest); + expect(useInventoryStore.getState().count?.Count.Status).toBe(2); +}); + +it('surfaces a voided snapshot as its code', async () => { + server.getCount.mockResolvedValue(detail(1, [2, 2])); + await useInventoryStore.getState().open('c-1'); + server.completeCount.mockRejectedValue({ response: { status: 409, data: { type: 'inventory_CountSnapshotChanged', code: 'CountSnapshotChanged' } } }); + expect(await useInventoryStore.getState().complete()).toBe(false); + expect(useInventoryStore.getState().error).toBe('CountSnapshotChanged'); +}); diff --git a/src/stores/inventory/store.ts b/src/stores/inventory/store.ts new file mode 100644 index 00000000..9aba046a --- /dev/null +++ b/src/stores/inventory/store.ts @@ -0,0 +1,143 @@ +import { randomUUID } from 'expo-crypto'; +import { create } from 'zustand'; + +import { cancelCount, completeCount, getCount, getCounts, getInventoryAccess, getUnitEquipment, saveCount, startCount } from '@/api/inventory/inventory'; +import { inventoryError, pendingObservations } from '@/lib/inventory/count'; +import type { InventoryCount, InventoryCountDetail, InventoryEquipment, InventoryFieldAccess } from '@/models/v4/inventory'; +import { InventoryCountStatus } from '@/models/v4/inventory'; +import useAuthStore from '@/stores/auth/store'; +import { securityStore } from '@/stores/security/store'; + +// The active unit's inventory on the apparatus tablet: what it carries (by compartment) and the counts the +// crew runs. Counted values stay on the device until saved; the server holds the draft between saves. + +export interface InventoryState { + identity: string | null; + unitId: number | null; + access: InventoryFieldAccess | null; + equipment: InventoryEquipment[]; + counts: InventoryCount[]; + count: InventoryCountDetail | null; + observed: Record; + /** The completion's idempotency key, kept across retries of the same count. */ + completionId: string | null; + busy: boolean; + error: string | null; + awaitingWitness: boolean; + load: (unitId: number) => Promise; + start: (locationId: string, name: string) => Promise; + open: (countId: string) => Promise; + setObserved: (lineId: string, quantity: number) => void; + save: () => Promise; + complete: () => Promise; + cancel: () => Promise; + close: () => void; +} + +const initial = { + identity: null as string | null, + unitId: null, + access: null, + equipment: [], + counts: [], + count: null, + observed: {}, + completionId: null, + busy: false, + error: null, + awaitingWitness: false, +}; + +const currentIdentity = (): string | null => { + const userId = useAuthStore.getState().userId; + const departmentId = securityStore.getState().rights?.DepartmentId; + return userId && departmentId ? `${userId}:${departmentId}` : null; +}; + +export const useInventoryStore = create()((set, get) => { + const settle = async (work: () => Promise): Promise => { + set({ busy: true, error: null }); + try { + return await work(); + } catch (error) { + set({ error: inventoryError(error) }); + return undefined; + } finally { + set({ busy: false }); + } + }; + + /** Push the counted values the server does not have yet; answers with the refreshed draft. */ + const flush = async (): Promise => { + const count = get().count; + if (!count) return null; + const pending = pendingObservations(count.Lines, get().observed); + let current = count; + for (let index = 0; index < pending.length; index += 100) current = await saveCount(current.Count.Id, current.Count.Revision, pending.slice(index, index + 100)); + set({ count: current }); + return current; + }; + + return { + ...initial, + load: async (unitId) => { + const identity = currentIdentity(); + if (get().identity !== identity || get().unitId !== unitId) set({ ...initial, identity, unitId }); + await settle(async () => { + const [access, equipment] = await Promise.all([getInventoryAccess(unitId), getUnitEquipment(unitId)]); + const root = access.UnitLocations.find((location) => location.IsRoot); + // Counts are only listed to people who may run them. + const counts = access.CanCount && root ? (await getCounts(root.Id)).Items : []; + const others = access.CanCount ? await Promise.all(access.UnitLocations.filter((location) => !location.IsRoot).map((location) => getCounts(location.Id).then((page) => page.Items))) : []; + set({ access, equipment, counts: [...counts, ...others.flat()].sort((a, b) => String(b.SnapshotOn ?? '').localeCompare(String(a.SnapshotOn ?? ''))) }); + }); + }, + start: async (locationId, name) => { + const started = await settle(async () => { + const count = await startCount(randomUUID(), locationId, name); + set({ count, observed: {}, completionId: null, awaitingWitness: false, counts: [count.Count, ...get().counts] }); + return count.Count.Id; + }); + return started ?? null; + }, + open: async (countId) => { + await settle(async () => { + const count = await getCount(countId); + set({ count, observed: {}, completionId: null, awaitingWitness: count.Count.Status === InventoryCountStatus.AwaitingWitness }); + }); + }, + setObserved: (lineId, quantity) => set({ observed: { ...get().observed, [lineId]: quantity } }), + save: async () => { + const saved = await settle(async () => { + await flush(); + set({ observed: {} }); + return true; + }); + return saved === true; + }, + complete: async () => { + const done = await settle(async () => { + const count = await flush(); + if (!count) return false; + const requestId = get().completionId ?? randomUUID(); + set({ completionId: requestId, observed: {} }); + const result = await completeCount(count.Count.Id, count.Count.Revision, requestId); + const refreshed = await getCount(count.Count.Id); + set({ count: refreshed, awaitingWitness: result.AwaitingWitness, counts: get().counts.map((row) => (row.Id === refreshed.Count.Id ? refreshed.Count : row)) }); + return true; + }); + return done === true; + }, + cancel: async () => { + const count = get().count; + if (!count) return false; + const cancelled = await settle(async () => { + await cancelCount(count.Count.Id, count.Count.Revision); + set({ count: null, observed: {}, counts: get().counts.map((row) => (row.Id === count.Count.Id ? { ...row, Status: InventoryCountStatus.Cancelled } : row)) }); + return true; + }); + return cancelled === true; + }, + close: () => set({ count: null, observed: {}, completionId: null, awaitingWitness: false, error: null }), + }; +}); diff --git a/src/stores/offline-queue/__tests__/store.test.ts b/src/stores/offline-queue/__tests__/store.test.ts index ae1e96b4..6df31186 100644 --- a/src/stores/offline-queue/__tests__/store.test.ts +++ b/src/stores/offline-queue/__tests__/store.test.ts @@ -143,6 +143,20 @@ describe('OfflineQueueStore', () => { const state = useOfflineQueueStore.getState(); expect(state.queuedEvents[0].retryCount).toBe(3); }); + + it('should exhaust retries at once for a permanent failure', () => { + const store = useOfflineQueueStore.getState(); + + store.updateEventStatus(eventId, QueuedEventStatus.FAILED, 'Rejected (HTTP 403)', { permanent: true }); + + const state = useOfflineQueueStore.getState(); + expect(state.queuedEvents[0].status).toBe(QueuedEventStatus.FAILED); + expect(state.queuedEvents[0].retryCount).toBe(state.queuedEvents[0].maxRetries); + expect(state.queuedEvents[0].nextRetryAt).toBeUndefined(); + // Not picked up again, but still listed as failed + expect(state.getPendingEvents()).toHaveLength(0); + expect(state.getFailedEvents()).toHaveLength(1); + }); }); describe('removeEvent', () => { diff --git a/src/stores/offline-queue/store.ts b/src/stores/offline-queue/store.ts index dc93e11c..411d3a86 100644 --- a/src/stores/offline-queue/store.ts +++ b/src/stores/offline-queue/store.ts @@ -25,7 +25,7 @@ interface OfflineQueueState { // Actions initializeNetworkListener: () => void; addEvent: (type: QueuedEventType, data: Record, maxRetries?: number) => string; - updateEventStatus: (eventId: string, status: QueuedEventStatus, error?: string) => void; + updateEventStatus: (eventId: string, status: QueuedEventStatus, error?: string, options?: { permanent?: boolean }) => void; removeEvent: (eventId: string) => void; getEventById: (eventId: string) => QueuedEvent | undefined; getEventsByType: (type: QueuedEventType) => QueuedEvent[]; @@ -144,7 +144,7 @@ export const useOfflineQueueStore = create()( }, // Update event status - updateEventStatus: (eventId: string, status: QueuedEventStatus, error?: string) => { + updateEventStatus: (eventId: string, status: QueuedEventStatus, error?: string, options?: { permanent?: boolean }) => { set((state) => ({ queuedEvents: state.queuedEvents.map((event) => { if (event.id === eventId) { @@ -155,8 +155,13 @@ export const useOfflineQueueStore = create()( error, }; - // Calculate next retry time if this is a failed attempt - if (status === QueuedEventStatus.FAILED && event.retryCount < event.maxRetries) { + if (status === QueuedEventStatus.FAILED && options?.permanent) { + // The server rejected the payload itself — retrying can only fail the same way, so + // exhaust the retries now. It stays listed as failed until pruned. + updatedEvent.retryCount = event.maxRetries; + updatedEvent.nextRetryAt = undefined; + } else if (status === QueuedEventStatus.FAILED && event.retryCount < event.maxRetries) { + // Calculate next retry time if this is a failed attempt const delay = RETRY_DELAY_BASE * Math.pow(2, event.retryCount); // Exponential backoff updatedEvent.nextRetryAt = Date.now() + delay; updatedEvent.retryCount = event.retryCount + 1; diff --git a/src/stores/operations/__tests__/store.test.ts b/src/stores/operations/__tests__/store.test.ts new file mode 100644 index 00000000..12a71608 --- /dev/null +++ b/src/stores/operations/__tests__/store.test.ts @@ -0,0 +1,173 @@ +jest.mock('@/api/operations/operations', () => ({ + getDeploymentAccess: jest.fn(), + getDeployments: jest.fn(), + getDeployment: jest.fn(), + getTimeReports: jest.fn(), + newTimeReport: jest.fn(), + saveTimeEntries: jest.fn(), + submitTimeReport: jest.fn(), + signTimeReport: jest.fn(), + approveTimeReport: jest.fn(), + getExpenses: jest.fn(), + saveExpense: jest.fn(), + deleteExpense: jest.fn(), + getFieldCostAccess: jest.fn(), + getResourceUsage: jest.fn(), + addResourceUsage: jest.fn(), + getCalOesMarsAccess: jest.fn(), + getCalOesMarsQueue: jest.fn(), + buildF42: jest.fn(), + validateCalOesMarsWorkItem: jest.fn(), +})); +jest.mock('@/lib/operations/capabilities', () => ({ + operationsCapabilities: { editTime: true, recordUsage: true, recordExpenses: true, approveTime: true, draftF42: true, homeRoute: '/', useActiveUnitId: () => null }, +})); +jest.mock('@/stores/auth/store', () => { + const { create } = jest.requireActual('zustand'); + return { __esModule: true, default: create(() => ({ userId: 'me' })) }; +}); +jest.mock('@/stores/security/store', () => { + const { create } = jest.requireActual('zustand'); + return { securityStore: create(() => ({ rights: { DepartmentId: '77' } })) }; +}); + +import * as api from '@/api/operations/operations'; +import { newEntry } from '@/lib/operations/time'; +import useAuthStore from '@/stores/auth/store'; +import { useOperationsStore } from '@/stores/operations/store'; + +const server = jest.mocked(api); +const deployment = { + Id: 'dep-1', + Name: 'LNU', + Status: 2, + FinanceMode: 1, + CallId: 9, + Currency: 'USD', + Units: [{ Id: 'du-1', UnitId: 12, UnitName: 'E-41', IsActive: true }], + Personnel: [{ Id: 'dp-1', UserId: 'me', Name: 'Me', DeploymentUnitId: 'du-1', IsActive: true }], + Equipment: [], + TimeAccess: { CanManage: false, CanApprove: true, PersonnelId: 'dp-1', CrewUnitIds: ['du-1'], WritableSubjectIds: ['dp-1', 'du-1'] }, +}; +const report = (status = 0, entries: unknown[] = [], patch: Record = {}) => ({ + Id: 'r-1', + DeploymentId: 'dep-1', + ReportNumber: 1, + ReportDate: '2026-09-19T00:00:00', + Scope: 1, + DeploymentUnitId: 'du-1', + CanAct: true, + Status: status, + Entries: entries, + ...patch, +}); +const answer = (data: unknown, errors: unknown[] = [], warnings: unknown[] = []) => ({ Data: data, Status: 'Ok', PageSize: 1, Errors: errors, Warnings: warnings }) as never; + +beforeEach(() => { + jest.clearAllMocks(); + useOperationsStore.setState({ identity: null, access: null, deployment: null, reports: [], scope: null, report: null, entries: [], dirty: false, issues: [], warnings: [], expenses: [], error: null }); + server.getDeploymentAccess.mockResolvedValue({ Enabled: true, CanManage: false, CanApproveTimeReports: false, ContractorBilling: false }); + server.getFieldCostAccess.mockResolvedValue({ Enabled: true, CanViewInternalCosts: false, CanRecordUsage: true }); + server.getCalOesMarsAccess.mockRejectedValue(new Error('no add-on')); + server.getDeployment.mockResolvedValue(deployment as never); + server.getTimeReports.mockResolvedValue([]); + server.newTimeReport.mockResolvedValue(answer(report())); +}); + +it('loads access without letting an unavailable add-on hide the deployments, and drops state on identity change', async () => { + useOperationsStore.setState({ identity: 'someone:77', deployments: [deployment as never] }); + await useOperationsStore.getState().loadAccess(); + const state = useOperationsStore.getState(); + expect(state.identity).toBe('me:77'); + expect(state.deployments).toEqual([]); + expect(state.access?.Enabled).toBe(true); + expect(state.costAccess?.CanRecordUsage).toBe(true); + expect(state.marsAccess).toBeNull(); + expect(state.error).toBeNull(); + useAuthStore.setState({ userId: 'me' }); +}); + +it('opens the crew report for the scope on demand, refuses invalid entries locally and submits only a clean saved report', async () => { + await useOperationsStore.getState().loadAccess(); + await useOperationsStore.getState().open('dep-1'); + useOperationsStore.getState().setScope({ kind: 'crew', unitId: 'du-1' }, '2026-09-19'); + expect(useOperationsStore.getState().report).toBeNull(); + await useOperationsStore.getState().openReport('2026-09-19', false); + expect(server.newTimeReport).not.toHaveBeenCalled(); + + await useOperationsStore.getState().openReport('2026-09-19', true); + expect(server.newTimeReport).toHaveBeenCalledWith('dep-1', '2026-09-19', { deploymentUnitId: 'du-1', deploymentPersonnelId: null }); + expect(useOperationsStore.getState().report?.Id).toBe('r-1'); + + const bad = { ...newEntry({ type: 0, id: 'dp-1', label: 'Me' }, '2026-09-19', 0), EndLocal: '2026-09-19T06:00' }; + useOperationsStore.getState().setEntries([bad]); + expect(await useOperationsStore.getState().save()).toBe(false); + expect(server.saveTimeEntries).not.toHaveBeenCalled(); + expect(useOperationsStore.getState().issues).toEqual([{ Code: 'end_before_start', EntryId: '0' }]); + expect(await useOperationsStore.getState().submit()).toBe(false); + + const good = newEntry({ type: 0, id: 'dp-1', label: 'Me' }, '2026-09-19', 0); + server.saveTimeEntries.mockResolvedValue(answer(report(0, [{ ...good, Id: 'e-1', Hours: 10 }]), [], [{ Code: 'break_rule' }])); + useOperationsStore.getState().setEntries([good]); + expect(await useOperationsStore.getState().save()).toBe(true); + expect(server.saveTimeEntries).toHaveBeenCalledWith('r-1', [good]); + expect(useOperationsStore.getState().dirty).toBe(false); + expect(useOperationsStore.getState().warnings).toEqual([{ Code: 'break_rule' }]); + + server.signTimeReport.mockResolvedValue(answer(report(0, [{ ...good, Id: 'e-1' }], { ContractorSignedOn: '2026-09-19T20:00:00Z' }))); + expect(await useOperationsStore.getState().sign(true)).toBe(true); + expect(server.signTimeReport).toHaveBeenCalledWith('r-1', true, undefined); + expect(useOperationsStore.getState().report?.ContractorSignedOn).toBe('2026-09-19T20:00:00Z'); + + server.submitTimeReport.mockResolvedValue(answer(report(1, [{ ...good, Id: 'e-1' }]))); + expect(await useOperationsStore.getState().submit()).toBe(true); + expect(useOperationsStore.getState().report?.Status).toBe(1); + expect(useOperationsStore.getState().reports.find((candidate) => candidate.Id === 'r-1')?.Status).toBe(1); + + server.approveTimeReport.mockResolvedValue(answer(report(2, [{ ...good, Id: 'e-1' }]))); + expect(await useOperationsStore.getState().approve('r-1')).toBe(true); + expect(useOperationsStore.getState().report?.Status).toBe(2); +}); + +it('keeps validation refusals from the server on the report and switches scope without leaking entries', async () => { + server.getTimeReports.mockResolvedValue([report(0, [{ Id: 'e-1', SubjectType: 0, DeploymentPersonnelId: 'dp-1', EntryType: 0, StartLocal: '2026-09-19T08:00', EndLocal: '2026-09-19T18:00' }])] as never); + await useOperationsStore.getState().open('dep-1'); + useOperationsStore.getState().setScope({ kind: 'crew', unitId: 'du-1' }, '2026-09-19'); + expect(useOperationsStore.getState().entries).toHaveLength(1); + + server.saveTimeEntries.mockResolvedValue(answer(report(0, []), [{ Code: 'subject_on_other_report', SubjectId: 'dp-1' }])); + useOperationsStore.getState().setEntries([...useOperationsStore.getState().entries]); + expect(await useOperationsStore.getState().save()).toBe(false); + expect(useOperationsStore.getState().issues).toEqual([{ Code: 'subject_on_other_report', SubjectId: 'dp-1' }]); + + useOperationsStore.getState().setScope({ kind: 'individual', personnelId: 'dp-1' }, '2026-09-19'); + expect(useOperationsStore.getState().report).toBeNull(); + expect(useOperationsStore.getState().entries).toEqual([]); + expect(useOperationsStore.getState().issues).toEqual([]); +}); + +it('files expenses against the open deployment and removes only by id', async () => { + await useOperationsStore.getState().open('dep-1'); + server.saveExpense.mockResolvedValue({ Id: 'x-1', DeploymentId: 'dep-1', ExpenseDate: '2026-09-19T00:00:00', ExpenseType: 4, Amount: 88.4, PreApproved: false, Billable: true } as never); + expect(await useOperationsStore.getState().addExpense({ ExpenseDate: '2026-09-19', ExpenseType: 4, Amount: 88.4, ReceiptData: 'AAAA', ReceiptFileName: 'r.jpg', ReceiptContentType: 'image/jpeg' })).toBe(true); + expect(server.saveExpense).toHaveBeenCalledWith(expect.objectContaining({ DeploymentId: 'dep-1', ExpenseType: 4, ReceiptData: 'AAAA' })); + expect(useOperationsStore.getState().expenses.map((expense) => expense.Id)).toEqual(['x-1']); + + server.deleteExpense.mockResolvedValue({} as never); + expect(await useOperationsStore.getState().removeExpense('x-1')).toBe(true); + expect(server.deleteExpense).toHaveBeenCalledWith('x-1'); + expect(useOperationsStore.getState().expenses).toEqual([]); +}); + +it('stamps usage readings with the open deployment and surfaces server refusals as reason codes', async () => { + await useOperationsStore.getState().loadAccess(); + await useOperationsStore.getState().open('dep-1'); + server.addResourceUsage.mockResolvedValue({ Id: 'u-1', UnitId: 12, UsageDate: '2026-09-19T00:00:00', Phase: 2, Source: 0, NeedsReview: false } as never); + expect(await useOperationsStore.getState().addUsage({ UnitId: 12, UsageDate: '2026-09-19T00:00:00', Phase: 2, Distance: 60, DistanceUnit: 'mi' })).toBe(true); + expect(server.addResourceUsage).toHaveBeenCalledWith(expect.objectContaining({ DeploymentId: 'dep-1', CallId: 9, UnitId: 12 })); + expect(useOperationsStore.getState().usage.map((reading) => reading.Id)).toEqual(['u-1']); + + server.addResourceUsage.mockRejectedValue({ response: { status: 400, headers: { 'x-resgrid-reason': 'usage_unit_not_rostered' } } }); + expect(await useOperationsStore.getState().addUsage({ UnitId: 99, UsageDate: '2026-09-19T00:00:00', Phase: 2 })).toBe(false); + expect(useOperationsStore.getState().error).toBe('usage_unit_not_rostered'); +}); diff --git a/src/stores/operations/store.ts b/src/stores/operations/store.ts new file mode 100644 index 00000000..ccfc4ca7 --- /dev/null +++ b/src/stores/operations/store.ts @@ -0,0 +1,311 @@ +import { create } from 'zustand'; + +import { + addResourceUsage, + approveTimeReport, + buildF42, + deleteExpense, + getCalOesMarsAccess, + getCalOesMarsQueue, + getDeployment, + getDeploymentAccess, + getDeployments, + getExpenses, + getFieldCostAccess, + getResourceUsage, + getTimeReports, + newTimeReport, + saveExpense, + saveTimeEntries, + signTimeReport, + submitTimeReport, + validateCalOesMarsWorkItem, +} from '@/api/operations/operations'; +import { operationsCapabilities } from '@/lib/operations/capabilities'; +import { operationsError, reportForScope, scopeKey, type TimeScope, validateEntries } from '@/lib/operations/time'; +import type { + CalOesMarsAccess, + CalOesMarsQueueItem, + CalOesMarsValidation, + Deployment, + DeploymentAccess, + Expense, + ExpenseInput, + FieldCostAccess, + ResourceUsage, + ResourceUsageInput, + TimeEntry, + TimeReport, + TimeReportIssue, + TimeReportResponse, +} from '@/models/v4/operations'; +import useAuthStore from '@/stores/auth/store'; +import { securityStore } from '@/stores/security/store'; + +// One store for the field-operations surface: the deployment list, the open deployment with its crew and +// individual time reports, expenses, resource usage readings and the CAL OES MARS work items that belong to +// it. Everything shown came from the server's own scoping (a rostered member or the seated crew of a deployed +// unit sees their deployments, a manager the department's); this store never decides what the person may see. + +export interface OperationsState { + identity: string | null; + access: DeploymentAccess | null; + costAccess: FieldCostAccess | null; + marsAccess: CalOesMarsAccess | null; + deployments: Deployment[]; + includeClosed: boolean; + deployment: Deployment | null; + reports: TimeReport[]; + scope: TimeScope | null; + report: TimeReport | null; + entries: TimeEntry[]; + dirty: boolean; + issues: TimeReportIssue[]; + warnings: TimeReportIssue[]; + expenses: Expense[]; + usage: ResourceUsage[]; + marsItems: CalOesMarsQueueItem[]; + validation: CalOesMarsValidation | null; + busy: boolean; + error: string | null; + loadAccess: () => Promise; + loadDeployments: (includeClosed?: boolean) => Promise; + open: (id: string) => Promise; + setScope: (scope: TimeScope | null, dateKey: string) => void; + openReport: (dateKey: string, create: boolean) => Promise; + selectReport: (reportId: string) => void; + setEntries: (entries: TimeEntry[]) => void; + save: () => Promise; + submit: () => Promise; + sign: (crewBossSigned: boolean, customerSignerName?: string | null) => Promise; + approve: (reportId: string) => Promise; + loadExpenses: () => Promise; + addExpense: (input: Omit) => Promise; + removeExpense: (id: string) => Promise; + loadUsage: () => Promise; + addUsage: (input: Omit) => Promise; + loadMars: () => Promise; + draftF42: () => Promise; + validate: (workItemId: string) => Promise; + close: () => void; +} + +const closed = { + deployment: null, + reports: [], + scope: null, + report: null, + entries: [], + dirty: false, + issues: [], + warnings: [], + expenses: [], + usage: [], + marsItems: [], + validation: null, +}; + +const initial = { + ...closed, + identity: null as string | null, + access: null, + costAccess: null, + marsAccess: null, + deployments: [], + includeClosed: false, + busy: false, + error: null, +}; + +const settle = async (work: () => Promise): Promise => { + useOperationsStore.setState({ busy: true, error: null }); + try { + return await work(); + } catch (error) { + useOperationsStore.setState({ error: operationsError(error) }); + return undefined; + } finally { + useOperationsStore.setState({ busy: false }); + } +}; + +// Immutable ids only (user id + department id): state from another sign-in or department is dropped +// before anything is fetched for this one, so a stale deployment can never be shown to the wrong person. +const currentIdentity = (): string | null => { + const userId = useAuthStore.getState().userId; + const departmentId = securityStore.getState().rights?.DepartmentId; + return userId && departmentId ? `${userId}:${departmentId}` : null; +}; + +const copyEntries = (report: TimeReport | null) => (report ? report.Entries.map((entry) => ({ ...entry })) : []); + +export const useOperationsStore = create()((set, get) => { + /** + * Adopt a report the server just answered with: refresh it in the day list and, when it is open, in the editor. + * A validation refusal comes back with the stored report; keep the person's unsaved edits and show the issues. + */ + const adopt = (response: TimeReportResponse) => { + const errors = response.Errors ?? []; + if (errors.length > 0) { + set({ issues: errors, warnings: response.Warnings ?? [] }); + return false; + } + const next = response.Data; + const open = get().report?.Id === next?.Id; + set({ + reports: next ? [...get().reports.filter((existing) => existing.Id !== next.Id), next] : get().reports, + ...(open || !get().report ? { report: next, entries: copyEntries(next), dirty: false } : {}), + issues: [], + warnings: response.Warnings ?? [], + }); + return true; + }; + + return { + ...initial, + loadAccess: async () => { + const identity = currentIdentity(); + if (get().identity !== identity) set({ ...initial, identity }); + await settle(async () => { + const access = await getDeploymentAccess(); + // Usage and MARS access are optional add-ons; a failure there must not hide the deployments. + const [costAccess, marsAccess] = await Promise.all([ + operationsCapabilities.recordUsage ? getFieldCostAccess().catch(() => null) : Promise.resolve(null), + operationsCapabilities.draftF42 ? getCalOesMarsAccess().catch(() => null) : Promise.resolve(null), + ]); + set({ access, costAccess, marsAccess }); + }); + }, + loadDeployments: async (includeClosed = get().includeClosed) => { + await settle(async () => { + const deployments = await getDeployments(!includeClosed); + set({ deployments, includeClosed }); + }); + }, + open: async (id) => { + await settle(async () => { + const [deployment, reports] = await Promise.all([getDeployment(id), getTimeReports(id)]); + set({ ...closed, deployment, reports }); + }); + }, + setScope: (scope, dateKey) => { + const report = reportForScope(get().reports, dateKey, scope); + set({ scope, report, entries: copyEntries(report), dirty: false, issues: [], warnings: [] }); + }, + openReport: async (dateKey, create) => { + const { deployment, scope } = get(); + if (!deployment) return; + let report = reportForScope(get().reports, dateKey, scope); + if (!report && create && scope && operationsCapabilities.editTime) { + await settle(async () => { + const response = await newTimeReport(deployment.Id, dateKey, { + deploymentUnitId: scope.kind === 'crew' ? scope.unitId : null, + deploymentPersonnelId: scope.kind === 'individual' ? scope.personnelId : null, + }); + report = response.Data; + set({ reports: [...get().reports, report] }); + }); + } + // The scope can change while the create is in flight; only the report that still matches it opens. + if (scopeKey(get().scope) !== scopeKey(scope)) return; + set({ report, entries: copyEntries(report), dirty: false, issues: [], warnings: [] }); + }, + selectReport: (reportId) => { + const report = get().reports.find((candidate) => candidate.Id === reportId) ?? null; + set({ report, entries: copyEntries(report), dirty: false, issues: [], warnings: [] }); + }, + setEntries: (entries) => set({ entries, dirty: true, issues: [] }), + save: async () => { + const report = get().report; + if (!report) return false; + const local = validateEntries(get().entries); + if (local.length > 0) { + set({ issues: local.map((issue) => ({ Code: issue.code, EntryId: String(issue.index) })) }); + return false; + } + const saved = await settle(async () => adopt(await saveTimeEntries(report.Id, get().entries))); + return saved === true; + }, + submit: async () => { + const report = get().report; + if (!report || get().dirty) return false; + const submitted = await settle(async () => adopt(await submitTimeReport(report.Id))); + return submitted === true; + }, + sign: async (crewBossSigned, customerSignerName) => { + const report = get().report; + if (!report || (!crewBossSigned && !customerSignerName)) return false; + const signed = await settle(async () => adopt(await signTimeReport(report.Id, crewBossSigned, customerSignerName))); + return signed === true; + }, + approve: async (reportId) => { + const approved = await settle(async () => adopt(await approveTimeReport(reportId))); + return approved === true; + }, + loadExpenses: async () => { + const deployment = get().deployment; + if (!deployment || !operationsCapabilities.recordExpenses) return; + await settle(async () => set({ expenses: await getExpenses(deployment.Id) })); + }, + addExpense: async (input) => { + const deployment = get().deployment; + if (!deployment) return false; + const added = await settle(async () => { + const expense = await saveExpense({ ...input, DeploymentId: deployment.Id }); + set({ expenses: [expense, ...get().expenses.filter((existing) => existing.Id !== expense.Id)] }); + return true; + }); + return added === true; + }, + removeExpense: async (id) => { + const removed = await settle(async () => { + await deleteExpense(id); + set({ expenses: get().expenses.filter((expense) => expense.Id !== id) }); + return true; + }); + return removed === true; + }, + loadUsage: async () => { + const deployment = get().deployment; + if (!deployment || !get().costAccess?.Enabled) return; + await settle(async () => set({ usage: await getResourceUsage(deployment.Id) })); + }, + addUsage: async (input) => { + const deployment = get().deployment; + if (!deployment) return false; + const added = await settle(async () => { + const reading = await addResourceUsage({ ...input, DeploymentId: deployment.Id, CallId: deployment.CallId ?? null }); + set({ usage: [reading, ...get().usage] }); + return true; + }); + return added === true; + }, + loadMars: async () => { + const deployment = get().deployment; + if (!deployment || !get().marsAccess?.Enabled) return; + await settle(async () => { + const queue = await getCalOesMarsQueue(); + set({ marsItems: queue.filter((item) => item.DeploymentId === deployment.Id) }); + }); + }, + draftF42: async () => { + const deployment = get().deployment; + if (!deployment) return false; + const built = await settle(async () => { + await buildF42(deployment.Id); + set({ marsItems: (await getCalOesMarsQueue()).filter((item) => item.DeploymentId === deployment.Id) }); + return true; + }); + return built === true; + }, + validate: async (workItemId) => { + const validated = await settle(async () => { + const validation = await validateCalOesMarsWorkItem(workItemId); + set({ validation }); + return validation.IsReadyForPortal; + }); + return validated === true; + }, + close: () => set({ ...closed, error: null }), + }; +}); diff --git a/src/stores/records/__tests__/deployments-store.test.ts b/src/stores/records/__tests__/deployments-store.test.ts new file mode 100644 index 00000000..57f3ddd6 --- /dev/null +++ b/src/stores/records/__tests__/deployments-store.test.ts @@ -0,0 +1,227 @@ +import { useDeploymentsStore } from '@/stores/records/deployments-store'; + +// Deployments and connector store conformance (RMS plan section 4.1). Identical in all four app +// repositories: the store reads, it issues exactly one command, and a refusal stays a refusal. + +jest.mock('@/api/records/deployments', () => ({ + getRecordDeployments: jest.fn(), + getRecordDeployment: jest.fn(), + getRecordDeploymentConnectors: jest.fn(), + getRecordDeploymentConnector: jest.fn(), + getRecordDeploymentConnectorRuns: jest.fn(), + getRecordDeploymentReconciliation: jest.fn(), + runRecordDeploymentConnector: jest.fn(), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const api = jest.requireMock('@/api/records/deployments'); + +const deployment = (orderId: string, modifiedOn: string, status = 'Open') => ({ + OrderId: orderId, + ProfileKey: 'local-mutual-aid', + ProfileVersion: 1, + SourceScheme: 'local', + OrderNumber: orderId.toUpperCase(), + IncidentName: 'Bear Creek', + HasArtifact: false, + Status: status, + OwnershipMarker: 'connector', + ConnectorId: 'c1', + AllReturned: false, + IsPreview: true, + CreatedOn: modifiedOn, + ModifiedOn: modifiedOn, + RowVersion: 1, + Fills: [], +}); + +const connector = (id: string, name: string, ready = true) => ({ + Id: id, + ProviderKey: 'generic-feed', + Name: name, + SourceScheme: 'local', + ProfileKey: 'local-mutual-aid', + BaseUrl: 'https://orders.example.gov/feed', + CredentialKind: 'bearer', + HasCredential: true, + HasInboundToken: true, + ReadEnabled: true, + WriteEnabled: false, + PollIntervalMinutes: 30, + MaxRequestsPerHour: 12, + RequestsThisHour: 1, + IsEnabled: ready, + IsReadyToRun: ready, + ConsecutiveFailures: 0, + CreatedOn: '2026-09-06T00:00:00Z', + ModifiedOn: '2026-09-06T00:00:00Z', + RowVersion: 1, +}); + +const run = (id: string, outcome = 'ok') => ({ + Id: id, + ConnectorId: 'c1', + Trigger: 'manual', + StartedOn: '2026-09-06T12:00:00Z', + Outcome: outcome, + Error: outcome === 'ok' ? null : 'The source answered 503 Service Unavailable.', + RequestCount: 1, + OrdersSeen: 2, + OrdersCreated: 1, + SnapshotsRecorded: 1, + RequestsAdded: 1, + Unchanged: 0, + Rejected: 0, + Conflicts: 1, + Messages: [], +}); + +const forbidden = () => Object.assign(new Error('Request failed with status code 403'), { response: { status: 403 } }); + +describe('Deployments store conformance', () => { + beforeEach(() => { + jest.clearAllMocks(); + useDeploymentsStore.getState().reset(); + }); + + it('holds the department deployments newest first and remembers the closed filter', async () => { + api.getRecordDeployments.mockResolvedValue({ Data: [deployment('o1', '2026-09-01T00:00:00Z'), deployment('o2', '2026-09-03T00:00:00Z', 'ClosedOut')] }); + + const list = await useDeploymentsStore.getState().fetchDeployments({ includeClosed: true }); + + expect(api.getRecordDeployments).toHaveBeenCalledWith(true); + expect(list.map((d) => d.OrderId)).toEqual(['o2', 'o1']); + expect(useDeploymentsStore.getState().includeClosed).toBe(true); + expect(useDeploymentsStore.getState().lastFetchedOn).not.toBeNull(); + expect(useDeploymentsStore.getState().error).toBeNull(); + }); + + it('keeps what it had and surfaces the error when the list cannot be read', async () => { + useDeploymentsStore.setState({ deployments: [deployment('o1', '2026-09-01T00:00:00Z')] }); + api.getRecordDeployments.mockRejectedValue(new Error('offline')); + + const list = await useDeploymentsStore.getState().fetchDeployments(); + + expect(list).toHaveLength(1); + expect(useDeploymentsStore.getState().error).toBe('offline'); + expect(useDeploymentsStore.getState().isLoading).toBe(false); + }); + + it('upserts a single deployment and drops one the server no longer returns', async () => { + useDeploymentsStore.setState({ deployments: [deployment('o1', '2026-09-01T00:00:00Z'), deployment('o2', '2026-09-02T00:00:00Z')] }); + api.getRecordDeployment.mockResolvedValueOnce({ Data: deployment('o1', '2026-09-05T00:00:00Z', 'Mobilized') }).mockResolvedValueOnce({ Data: null }); + + const fresh = await useDeploymentsStore.getState().fetchDeployment('o1'); + expect(fresh?.Status).toBe('Mobilized'); + expect(useDeploymentsStore.getState().deployments.map((d) => d.OrderId)).toEqual(['o1', 'o2']); + + const gone = await useDeploymentsStore.getState().fetchDeployment('o2'); + expect(gone).toBeNull(); + expect(useDeploymentsStore.getState().deployments.map((d) => d.OrderId)).toEqual(['o1']); + expect(useDeploymentsStore.getState().error).toBe('not_found'); + }); + + it('replaces only one connector slice of the reconciliation when asked for that connector', async () => { + useDeploymentsStore.setState({ + reconciliation: [ + { ConnectorId: 'c1', OrderId: 'o1', OrderNumber: 'O1', Kind: 'source_status_ahead', RequestNumber: 'O-1' }, + { ConnectorId: 'c2', OrderId: 'o9', OrderNumber: 'O9', Kind: 'source_closed_local_open' }, + ], + }); + api.getRecordDeploymentReconciliation.mockResolvedValue({ Data: [{ ConnectorId: 'c1', OrderId: 'o1', OrderNumber: 'O1', Kind: 'local_fill_missing_in_source', RequestNumber: 'L-7' }] }); + + await useDeploymentsStore.getState().fetchReconciliation('c1'); + + expect(api.getRecordDeploymentReconciliation).toHaveBeenCalledWith('c1'); + expect(useDeploymentsStore.getState().reconciliation.map((i) => `${i.ConnectorId}:${i.Kind}`)).toEqual(['c2:source_closed_local_open', 'c1:local_fill_missing_in_source']); + + api.getRecordDeploymentReconciliation.mockResolvedValue({ Data: [] }); + await useDeploymentsStore.getState().fetchReconciliation(); + expect(useDeploymentsStore.getState().reconciliation).toEqual([]); + }); + + it('shows a refusal as a refusal rather than retrying or hiding it', async () => { + api.getRecordDeploymentConnectors.mockRejectedValue(forbidden()); + + const connectors = await useDeploymentsStore.getState().fetchConnectors(); + + expect(connectors).toEqual([]); + expect(useDeploymentsStore.getState().connectorsError).toBe('forbidden'); + expect(api.getRecordDeploymentConnectors).toHaveBeenCalledTimes(1); + }); + + it('lists connectors by name and keeps their run logs per connector', async () => { + api.getRecordDeploymentConnectors.mockResolvedValue({ Data: [connector('c2', 'Zulu'), connector('c1', 'Alpha', false)] }); + api.getRecordDeploymentConnectorRuns.mockResolvedValue({ Data: [run('r1'), run('r0', 'failed')] }); + + const connectors = await useDeploymentsStore.getState().fetchConnectors(); + const runs = await useDeploymentsStore.getState().fetchRuns('c1'); + + expect(connectors.map((c) => c.Name)).toEqual(['Alpha', 'Zulu']); + expect(runs.map((r) => r.Id)).toEqual(['r1', 'r0']); + expect(useDeploymentsStore.getState().runs.c1).toHaveLength(2); + expect(useDeploymentsStore.getState().runs.c2).toBeUndefined(); + }); + + it('reads the feed now, records the run first in the log, then re-reads everything the run may have changed', async () => { + useDeploymentsStore.setState({ connectors: [connector('c1', 'Alpha')], runs: { c1: [run('r0')] } }); + api.runRecordDeploymentConnector.mockResolvedValue({ Data: run('r1') }); + api.getRecordDeploymentConnector.mockResolvedValue({ Data: { ...connector('c1', 'Alpha'), LastPolledOn: '2026-09-06T12:00:00Z' } }); + api.getRecordDeploymentReconciliation.mockResolvedValue({ Data: [{ ConnectorId: 'c1', OrderId: 'o1', OrderNumber: 'O1', Kind: 'source_status_ahead' }] }); + api.getRecordDeployments.mockResolvedValue({ Data: [deployment('o1', '2026-09-06T12:00:00Z')] }); + + const result = await useDeploymentsStore.getState().runConnector('c1'); + + expect(result.ok).toBe(true); + expect(result.run?.Id).toBe('r1'); + expect(api.runRecordDeploymentConnector).toHaveBeenCalledWith('c1'); + const state = useDeploymentsStore.getState(); + expect(state.runs.c1.map((r) => r.Id)).toEqual(['r1', 'r0']); + expect(state.connectors[0].LastPolledOn).toBe('2026-09-06T12:00:00Z'); + expect(state.reconciliation).toHaveLength(1); + expect(state.deployments).toHaveLength(1); + expect(state.runningConnectorId).toBeNull(); + }); + + it('reports a run that did not succeed with the server outcome and refuses to overlap runs', async () => { + api.runRecordDeploymentConnector.mockResolvedValue({ Data: run('r1', 'rate_limited') }); + api.getRecordDeploymentConnector.mockResolvedValue({ Data: connector('c1', 'Alpha') }); + api.getRecordDeploymentReconciliation.mockResolvedValue({ Data: [] }); + api.getRecordDeployments.mockResolvedValue({ Data: [] }); + + const result = await useDeploymentsStore.getState().runConnector('c1'); + expect(result.ok).toBe(false); + expect(result.error).toContain('503'); + + useDeploymentsStore.setState({ runningConnectorId: 'c9' }); + const overlapped = await useDeploymentsStore.getState().runConnector('c1'); + expect(overlapped).toEqual({ ok: false, error: 'busy' }); + expect(api.runRecordDeploymentConnector).toHaveBeenCalledTimes(1); + }); + + it('turns a refused run into a refusal and clears the running flag', async () => { + api.runRecordDeploymentConnector.mockRejectedValue(forbidden()); + + const result = await useDeploymentsStore.getState().runConnector('c1'); + + expect(result).toEqual({ ok: false, error: 'forbidden' }); + expect(useDeploymentsStore.getState().runningConnectorId).toBeNull(); + expect(useDeploymentsStore.getState().connectorsError).toBe('forbidden'); + }); + + it('reset drops everything, including connector rows that must not outlive the session', () => { + useDeploymentsStore.setState({ deployments: [deployment('o1', '2026-09-01T00:00:00Z')], connectors: [connector('c1', 'Alpha')], runs: { c1: [run('r1')] }, reconciliation: [], error: 'x', connectorsError: 'y' }); + + useDeploymentsStore.getState().reset(); + + const state = useDeploymentsStore.getState(); + expect(state.deployments).toEqual([]); + expect(state.connectors).toEqual([]); + expect(state.runs).toEqual({}); + expect(state.error).toBeNull(); + expect(state.connectorsError).toBeNull(); + }); +}); diff --git a/src/stores/records/__tests__/store.test.ts b/src/stores/records/__tests__/store.test.ts new file mode 100644 index 00000000..54136c21 --- /dev/null +++ b/src/stores/records/__tests__/store.test.ts @@ -0,0 +1,287 @@ +import { goldenCatalogEntry } from '@/lib/records/__tests__/fixtures'; +import { RmsRecordState } from '@/models/v4/records'; +import { RECORDS_ORIGIN_CLIENT, useRecordsStore } from '@/stores/records/store'; + +// Shared conformance suite for the Field Records store (RMS plan RMS-1D). Identical in all four app +// repositories apart from the origin each one reports, which is asserted here rather than assumed. + +jest.mock('@/lib/records/uploads', () => ({ + runUpload: jest.fn(), + cancelUpload: jest.fn(), +})); + +jest.mock('@/api/records/field-records', () => ({ + reportFieldRecordTelemetry: jest.fn(), + getFieldRecordsPreflight: jest.fn(), + getFieldRecordsCatalog: jest.fn(), + getFieldRecordPrefill: jest.fn(), + syncFieldRecords: jest.fn(), + getFieldRecordAssignments: jest.fn(), + getAssignmentsForRecord: jest.fn(), + acknowledgeAssignment: jest.fn(), + completeAssignment: jest.fn(), +})); + +jest.mock('@/api/records/records', () => ({ + getRecord: jest.fn(), + getRecordDefinitionVersion: jest.fn(), + createRecordDraft: jest.fn(), + saveRecordDraft: jest.fn(), + submitRecordForReview: jest.fn(), + finalizeRecord: jest.fn(), + cancelRecord: jest.fn(), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const fieldApi = jest.requireMock('@/api/records/field-records'); +const recordsApi = jest.requireMock('@/api/records/records'); +const uploads = jest.requireMock('@/lib/records/uploads'); + +const resetStore = () => { + // reset() also drops queued telemetry, which is module state a setState cannot reach. + useRecordsStore.getState().reset(); + useRecordsStore.setState({ + preflight: null, + catalog: null, + assignments: [], + recent: [], + drafts: [], + pendingDrafts: {}, + pendingUploads: {}, + uploadProgress: {}, + schemas: {}, + scopeStamp: null, + lastSyncTimestampMs: 0, + isLoading: false, + isSyncing: false, + error: null, + context: {}, + }); +}; + +const summary = (id: string, state: number, modifiedOn: string) => ({ + RecordId: id, + DefinitionVersion: 3, + State: state, + CreatedOn: modifiedOn, + ModifiedOn: modifiedOn, + RowVersion: 1, +}); + +describe('Field Records store conformance', () => { + beforeEach(() => { + jest.clearAllMocks(); + resetStore(); + }); + + it('reports this app as its own field origin', () => { + expect([2, 3, 4, 5]).toContain(RECORDS_ORIGIN_CLIENT as number); + }); + + it('keeps the sync delta and drops what the caller may no longer read', async () => { + useRecordsStore.setState({ recent: [summary('r1', RmsRecordState.Finalized, '2026-09-01T00:00:00Z'), summary('r9', RmsRecordState.Finalized, '2026-09-01T00:00:00Z')] }); + fieldApi.syncFieldRecords.mockResolvedValue({ + Data: { + Ok: true, + ResetRequired: false, + ScopeStamp: 'scope-1', + ServerTimestampMs: 1000, + Records: [summary('r2', RmsRecordState.Finalized, '2026-09-02T00:00:00Z')], + Tombstones: ['r9'], + Drafts: [summary('d1', RmsRecordState.Draft, '2026-09-02T00:00:00Z')], + Assignments: [{ AssignmentId: 'a1', RecordId: 'r2', AssigneeKind: 'Person', Purpose: 'complete', State: 'Open', CreatedOn: '2026-09-02T00:00:00Z', RowVersion: 1 }], + }, + }); + + await useRecordsStore.getState().sync(); + + const state = useRecordsStore.getState(); + expect(state.recent.map((record) => record.RecordId)).toEqual(['r2', 'r1']); + expect(state.recent.some((record) => record.RecordId === 'r9')).toBe(false); + expect(state.drafts).toHaveLength(1); + expect(state.assignments).toHaveLength(1); + expect(state.scopeStamp).toBe('scope-1'); + expect(state.lastSyncTimestampMs).toBe(1000); + }); + + it('clears the cached working set and re-pulls when the server says the scope changed', async () => { + useRecordsStore.setState({ recent: [summary('r1', RmsRecordState.Finalized, '2026-09-01T00:00:00Z')], lastSyncTimestampMs: 500, scopeStamp: 'scope-0' }); + fieldApi.syncFieldRecords + .mockResolvedValueOnce({ Data: { Ok: true, ResetRequired: true, ScopeStamp: 'scope-2', ServerTimestampMs: 0, Records: [], Tombstones: [], Drafts: [], Assignments: [] } }) + .mockResolvedValueOnce({ Data: { Ok: true, ResetRequired: false, ScopeStamp: 'scope-2', ServerTimestampMs: 900, Records: [], Tombstones: [], Drafts: [], Assignments: [] } }); + + await useRecordsStore.getState().sync(); + + expect(useRecordsStore.getState().recent).toEqual([]); + expect(useRecordsStore.getState().scopeStamp).toBe('scope-2'); + expect(fieldApi.syncFieldRecords).toHaveBeenCalledTimes(2); + expect(fieldApi.syncFieldRecords.mock.calls[1][0].Since).toBe(0); + }); + + it('refuses to stage a protected definition on the device', () => { + useRecordsStore.setState({ catalog: { ContractVersion: 'field-catalog.v1', OriginClient: 'Unit', Ok: true, Reasons: [], ContextVerified: true, Definitions: [goldenCatalogEntry({ RequiresProtectedGrant: true })], Exclusions: [], ServerTimestampMs: 0 } }); + + useRecordsStore.getState().stageDraft({ + clientRecordId: 'draft-1', + recordId: null, + definitionKey: 'shift-log', + definitionVersion: 3, + name: 'Shift log', + values: [], + updatedOn: '2026-09-06T00:00:00Z', + }); + + expect(useRecordsStore.getState().pendingDrafts).toEqual({}); + }); + + it('keeps a conflicted draft and never replays it in the batch push', async () => { + useRecordsStore.setState({ catalog: { ContractVersion: 'field-catalog.v1', OriginClient: 'Unit', Ok: true, Reasons: [], ContextVerified: true, Definitions: [goldenCatalogEntry()], Exclusions: [], ServerTimestampMs: 0 } }); + recordsApi.createRecordDraft.mockRejectedValue({ response: { status: 409, data: { type: 'record_concurrency', title: 'Changed' } } }); + + useRecordsStore.getState().stageDraft({ + clientRecordId: 'draft-2', + recordId: null, + definitionKey: 'shift-log', + definitionVersion: 3, + name: 'Shift log', + values: [], + updatedOn: '2026-09-06T00:00:00Z', + }); + + const result = await useRecordsStore.getState().pushDraft('draft-2'); + + expect(result.ok).toBe(false); + expect(result.conflict).toBe('etag'); + expect(useRecordsStore.getState().pendingDrafts['draft-2'].conflict).toBe('etag'); + + recordsApi.createRecordDraft.mockClear(); + await useRecordsStore.getState().pushAllDrafts(); + expect(recordsApi.createRecordDraft).not.toHaveBeenCalled(); + }); + + it('clears a staged draft once the server accepts it', async () => { + useRecordsStore.setState({ catalog: { ContractVersion: 'field-catalog.v1', OriginClient: 'Unit', Ok: true, Reasons: [], ContextVerified: true, Definitions: [goldenCatalogEntry()], Exclusions: [], ServerTimestampMs: 0 } }); + recordsApi.createRecordDraft.mockResolvedValue({ Data: { RecordId: 'server-1', DefinitionVersion: 3, State: RmsRecordState.Draft, RowVersion: 1 } }); + fieldApi.syncFieldRecords.mockResolvedValue({ Data: { Ok: true, ResetRequired: false, ScopeStamp: 'scope-1', ServerTimestampMs: 10, Records: [], Tombstones: [], Drafts: [], Assignments: [] } }); + + useRecordsStore.getState().stageDraft({ + clientRecordId: 'draft-3', + recordId: null, + definitionKey: 'shift-log', + definitionVersion: 3, + name: 'Shift log', + values: [], + updatedOn: '2026-09-06T00:00:00Z', + }); + + const result = await useRecordsStore.getState().pushDraft('draft-3'); + + expect(result).toMatchObject({ ok: true, recordId: 'server-1' }); + expect(useRecordsStore.getState().pendingDrafts['draft-3']).toBeUndefined(); + expect(recordsApi.createRecordDraft).toHaveBeenCalledWith(expect.objectContaining({ IdempotencyKey: 'draft-3', OriginClient: RECORDS_ORIGIN_CLIENT })); + }); + + it('keeps an interrupted upload so it can resume, and never retries a missing file', async () => { + const upload = { + localId: 'upload-1', + recordId: 'r1', + uploadId: null, + fileUri: 'file:///photo.jpg', + fileName: 'photo.jpg', + contentType: 'image/jpeg', + byteSize: 900, + sha256: 'abc', + sentBytes: 0, + classification: 1, + createdOn: '2026-09-06T00:00:00Z', + }; + + uploads.runUpload.mockResolvedValueOnce({ ok: false, code: 'chunk_failed', sentBytes: 300, uploadId: 'session-1', message: 'Lost signal' }); + useRecordsStore.getState().stageUpload(upload); + let outcome = await useRecordsStore.getState().runUploads('r1'); + + expect(outcome).toEqual({ uploaded: 0, failed: 1 }); + const kept = useRecordsStore.getState().pendingUploads['upload-1']; + expect(kept.sentBytes).toBe(300); + expect(kept.uploadId).toBe('session-1'); + expect(kept.isUnrecoverable).toBeFalsy(); + + uploads.runUpload.mockResolvedValueOnce({ ok: false, code: 'file_missing' }); + outcome = await useRecordsStore.getState().runUploads('r1'); + expect(useRecordsStore.getState().pendingUploads['upload-1'].isUnrecoverable).toBe(true); + + uploads.runUpload.mockClear(); + outcome = await useRecordsStore.getState().runUploads('r1'); + expect(uploads.runUpload).not.toHaveBeenCalled(); + expect(outcome).toEqual({ uploaded: 0, failed: 0 }); + }); + + it('clears an upload once the server has the attachment', async () => { + uploads.runUpload.mockResolvedValue({ ok: true, attachment: { AttachmentId: 'a1' }, sentBytes: 900 }); + useRecordsStore.getState().stageUpload({ + localId: 'upload-2', + recordId: 'r1', + uploadId: null, + fileUri: 'file:///photo.jpg', + fileName: 'photo.jpg', + contentType: 'image/jpeg', + byteSize: 900, + sha256: 'abc', + sentBytes: 0, + classification: 1, + createdOn: '2026-09-06T00:00:00Z', + }); + + const outcome = await useRecordsStore.getState().runUploads('r1'); + + expect(outcome).toEqual({ uploaded: 1, failed: 0 }); + expect(useRecordsStore.getState().pendingUploads['upload-2']).toBeUndefined(); + expect(useRecordsStore.getState().uploadProgress['upload-2']).toBeUndefined(); + }); + + it('hands a discarded upload back to the server and batches telemetry without content', async () => { + useRecordsStore.getState().stageUpload({ + localId: 'upload-3', + recordId: 'r1', + uploadId: 'session-9', + fileUri: 'file:///photo.jpg', + fileName: 'photo.jpg', + contentType: 'image/jpeg', + byteSize: 10, + sha256: 'abc', + sentBytes: 0, + classification: 1, + createdOn: '2026-09-06T00:00:00Z', + }); + + await useRecordsStore.getState().discardUpload('upload-3'); + + expect(uploads.cancelUpload).toHaveBeenCalledWith('session-9'); + expect(useRecordsStore.getState().pendingUploads['upload-3']).toBeUndefined(); + + fieldApi.reportFieldRecordTelemetry.mockResolvedValue({ Data: { Accepted: 1 } }); + useRecordsStore.getState().report({ EventType: 'attachment', Outcome: 'ok', RecordId: 'r1', ItemCount: 12 }); + await useRecordsStore.getState().flushTelemetry(); + + const batch = fieldApi.reportFieldRecordTelemetry.mock.calls[0][0]; + expect(batch.OriginClient).toBe(RECORDS_ORIGIN_CLIENT); + expect(batch.Events[0]).toMatchObject({ EventType: 'attachment', Outcome: 'ok', ItemCount: 12 }); + expect(JSON.stringify(batch)).not.toContain('photo.jpg'); + + fieldApi.reportFieldRecordTelemetry.mockClear(); + await useRecordsStore.getState().flushTelemetry(); + expect(fieldApi.reportFieldRecordTelemetry).not.toHaveBeenCalled(); + }); + + it('drops the catalog when the context changes so it is never shown against another context', () => { + useRecordsStore.setState({ catalog: { ContractVersion: 'field-catalog.v1', OriginClient: 'Unit', Ok: true, Reasons: [], ContextVerified: true, Definitions: [goldenCatalogEntry()], Exclusions: [], ServerTimestampMs: 0 } }); + + useRecordsStore.getState().setContext({ CallId: 501 }); + + expect(useRecordsStore.getState().catalog).toBeNull(); + expect(useRecordsStore.getState().context).toEqual({ CallId: 501 }); + }); +}); diff --git a/src/stores/records/deployments-store.ts b/src/stores/records/deployments-store.ts new file mode 100644 index 00000000..f8ad5e03 --- /dev/null +++ b/src/stores/records/deployments-store.ts @@ -0,0 +1,217 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import { + getRecordDeployment, + getRecordDeploymentConnector, + getRecordDeploymentConnectorRuns, + getRecordDeploymentConnectors, + getRecordDeploymentReconciliation, + getRecordDeployments, + runRecordDeploymentConnector, +} from '@/api/records/deployments'; +import { logger } from '@/lib/logging'; +import { sortDeployments, upsertDeployment } from '@/lib/records/deployments'; +import { zustandStorage } from '@/lib/storage'; +import { type RecordDeploymentConnectorData, type RecordDeploymentConnectorRunData, type RecordDeploymentData, type RecordDeploymentReconciliationData } from '@/models/v4/records/deployments'; + +// Deployments and external ordering-system connectors for this app (RMS plan section 4.1). The +// server owns every list and every decision; this store holds what it returned so the screens can +// show it, and issues exactly one command: "read the feed now". Nothing here changes a fill, an +// order or a connector, and nothing here decides who is an administrator — a refused call is shown +// as a refusal, never retried as someone else. + +export interface DeploymentsState { + deployments: RecordDeploymentData[]; + includeClosed: boolean; + reconciliation: RecordDeploymentReconciliationData[]; + connectors: RecordDeploymentConnectorData[]; + runs: Record; + isLoading: boolean; + runningConnectorId: string | null; + error: string | null; + connectorsError: string | null; + lastFetchedOn: string | null; + + fetchDeployments: (options?: { includeClosed?: boolean }) => Promise; + fetchDeployment: (orderId: string) => Promise; + fetchReconciliation: (connectorId?: string | null) => Promise; + fetchConnectors: () => Promise; + fetchConnector: (connectorId: string) => Promise; + fetchRuns: (connectorId: string) => Promise; + runConnector: (connectorId: string) => Promise<{ ok: boolean; run?: RecordDeploymentConnectorRunData; error?: string }>; + reset: () => void; +} + +const messageOf = (error: unknown, fallback: string): string => { + const status = (error as { response?: { status?: number } })?.response?.status; + if (status === 403) { + return 'forbidden'; + } + if (status === 404) { + return 'not_found'; + } + return error instanceof Error && error.message ? error.message : fallback; +}; + +export const useDeploymentsStore = create()( + persist( + (set, get) => ({ + deployments: [], + includeClosed: false, + reconciliation: [], + connectors: [], + runs: {}, + isLoading: false, + runningConnectorId: null, + error: null, + connectorsError: null, + lastFetchedOn: null, + + fetchDeployments: async (options) => { + const includeClosed = options?.includeClosed ?? get().includeClosed; + set({ isLoading: true, error: null, includeClosed }); + try { + const response = await getRecordDeployments(includeClosed); + const deployments = sortDeployments(response?.Data ?? []); + set({ deployments, isLoading: false, lastFetchedOn: new Date().toISOString() }); + return deployments; + } catch (error) { + logger.error({ message: 'Deployments fetch failed', context: { error } }); + set({ isLoading: false, error: messageOf(error, 'load_failed') }); + return get().deployments; + } + }, + + fetchDeployment: async (orderId) => { + set({ isLoading: true, error: null }); + try { + const response = await getRecordDeployment(orderId); + const deployment = response?.Data ?? null; + if (deployment) { + set({ deployments: upsertDeployment(get().deployments, deployment), isLoading: false }); + } else { + // Gone or no longer visible: it is dropped rather than shown from the last fetch. + set({ deployments: get().deployments.filter((existing) => existing.OrderId !== orderId), isLoading: false, error: 'not_found' }); + } + return deployment; + } catch (error) { + logger.error({ message: 'Deployment fetch failed', context: { error, orderId } }); + set({ isLoading: false, error: messageOf(error, 'load_failed') }); + return get().deployments.find((existing) => existing.OrderId === orderId) ?? null; + } + }, + + fetchReconciliation: async (connectorId) => { + try { + const response = await getRecordDeploymentReconciliation(connectorId); + const items = response?.Data ?? []; + // One connector's items replace only that connector's slice; the rest stays as last seen. + const kept = connectorId ? get().reconciliation.filter((item) => item.ConnectorId !== connectorId) : []; + set({ reconciliation: [...kept, ...items], connectorsError: null }); + return items; + } catch (error) { + logger.error({ message: 'Reconciliation fetch failed', context: { error, connectorId } }); + set({ connectorsError: messageOf(error, 'load_failed') }); + return []; + } + }, + + fetchConnectors: async () => { + set({ connectorsError: null }); + try { + const response = await getRecordDeploymentConnectors(); + const connectors = (response?.Data ?? []).slice().sort((a, b) => a.Name.localeCompare(b.Name)); + set({ connectors }); + return connectors; + } catch (error) { + logger.error({ message: 'Connectors fetch failed', context: { error } }); + set({ connectorsError: messageOf(error, 'load_failed') }); + return []; + } + }, + + fetchConnector: async (connectorId) => { + try { + const response = await getRecordDeploymentConnector(connectorId); + const connector = response?.Data ?? null; + if (connector) { + set({ connectors: [connector, ...get().connectors.filter((existing) => existing.Id !== connectorId)].sort((a, b) => a.Name.localeCompare(b.Name)), connectorsError: null }); + } + return connector; + } catch (error) { + logger.error({ message: 'Connector fetch failed', context: { error, connectorId } }); + set({ connectorsError: messageOf(error, 'load_failed') }); + return get().connectors.find((existing) => existing.Id === connectorId) ?? null; + } + }, + + fetchRuns: async (connectorId) => { + try { + const response = await getRecordDeploymentConnectorRuns(connectorId); + const runs = response?.Data ?? []; + set({ runs: { ...get().runs, [connectorId]: runs } }); + return runs; + } catch (error) { + logger.error({ message: 'Connector runs fetch failed', context: { error, connectorId } }); + set({ connectorsError: messageOf(error, 'load_failed') }); + return get().runs[connectorId] ?? []; + } + }, + + runConnector: async (connectorId) => { + if (get().runningConnectorId) { + return { ok: false, error: 'busy' }; + } + set({ runningConnectorId: connectorId, connectorsError: null }); + try { + const response = await runRecordDeploymentConnector(connectorId); + const run = response?.Data; + if (!run) { + set({ runningConnectorId: null, connectorsError: 'load_failed' }); + return { ok: false, error: 'load_failed' }; + } + // The run row is the newest entry in that connector's log; then everything it may have + // changed is re-read from the server rather than guessed at from the counts. + set({ runs: { ...get().runs, [connectorId]: [run, ...(get().runs[connectorId] ?? []).filter((existing) => existing.Id !== run.Id)] }, runningConnectorId: null }); + await Promise.all([get().fetchConnector(connectorId), get().fetchReconciliation(connectorId), get().fetchDeployments()]); + return { ok: run.Outcome === 'ok', run, error: run.Outcome === 'ok' ? undefined : (run.Error ?? run.Outcome) }; + } catch (error) { + logger.error({ message: 'Connector run failed', context: { error, connectorId } }); + const message = messageOf(error, 'run_failed'); + set({ runningConnectorId: null, connectorsError: message }); + return { ok: false, error: message }; + } + }, + + reset: () => + set({ + deployments: [], + includeClosed: false, + reconciliation: [], + connectors: [], + runs: {}, + isLoading: false, + runningConnectorId: null, + error: null, + connectorsError: null, + lastFetchedOn: null, + }), + }), + { + name: 'records-deployments-storage', + storage: createJSONStorage(() => zustandStorage), + // The department's own deployments are worth a glance offline. Connector rows carry feed roots + // and error text, and run logs carry source messages, so neither is written to the device. + partialize: (state) => ({ + deployments: state.deployments, + includeClosed: state.includeClosed, + lastFetchedOn: state.lastFetchedOn, + }), + } + ) +); + +export const useOpenDeploymentCount = () => useDeploymentsStore((state) => state.deployments.filter((deployment) => deployment.Status !== 'ClosedOut').length); + +export const useReconciliationCount = () => useDeploymentsStore((state) => state.reconciliation.length); diff --git a/src/stores/records/store.ts b/src/stores/records/store.ts new file mode 100644 index 00000000..ae32fbde --- /dev/null +++ b/src/stores/records/store.ts @@ -0,0 +1,611 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import { + acknowledgeAssignment as acknowledgeAssignmentApi, + completeAssignment as completeAssignmentApi, + getFieldRecordPrefill, + getFieldRecordsCatalog, + getFieldRecordsPreflight, + reportFieldRecordTelemetry, + syncFieldRecords, +} from '@/api/records/field-records'; +import { createRecordDraft, finalizeRecord, getRecordDefinitionVersion, saveRecordDraft, submitRecordForReview } from '@/api/records/records'; +import { Env } from '@/lib/env'; +import { logger } from '@/lib/logging'; +import { canAuthorOffline, CLIENT_CAPABILITY, toValueList, type ValueMap } from '@/lib/records/schema'; +import { type PendingUpload, type UploadOptions, type UploadOutcome } from '@/lib/records/uploads'; +import { zustandStorage } from '@/lib/storage'; +import { + type FieldRecordAssignmentData, + type FieldRecordCatalogData, + type FieldRecordCatalogEntry, + type FieldRecordConflictKind, + type FieldRecordContextInput, + type FieldRecordPrefillData, + type FieldRecordPreflightData, + type RecordDefinitionSchema, + type RecordSummaryData, + type RecordValueInput, + RmsOriginClient, + RmsRecordState, +} from '@/models/v4/records'; + +// Field Records working set for this app (RMS plan RMS-1D). The server owns every filter; this store +// holds what it returned, the drafts this device is carrying, and the conflicts a person must resolve. +// Nothing here widens access: a tombstoned or unauthorized Record is dropped rather than displayed. + +/** This app's origin. Every other app repo ships the same store with its own value. */ +export const RECORDS_ORIGIN_CLIENT: RmsOriginClient = RmsOriginClient.Unit; + +const SYNC_TAKE = 200; + +/** Most rollout events held before the oldest are dropped; telemetry never grows without a bound. */ +const TELEMETRY_MAX = 100; + +/** One safe rollout datapoint (RMS plan RMS-1D): a coded outcome, a count, a duration. Never content. */ +export interface FieldRecordTelemetryEvent { + EventType: string; + Outcome?: string; + DefinitionKey?: string; + DefinitionVersion?: number; + RecordId?: string; + DurationMs?: number; + ItemCount?: number; + OccurredOn?: string; +} + +// Module scope rather than store state: the queue is transient, must not be persisted, and must not +// make every consumer of the store re-render each time an event is added. +let telemetryQueue: FieldRecordTelemetryEvent[] = []; + +// The upload driver reaches for the camera roll and the crypto module, so it is loaded only when an +// upload actually runs. Importing it here would drag those native modules into every screen that +// touches this store, which is most of them. +interface UploaderModule { + runUpload: (pending: PendingUpload, options?: UploadOptions) => Promise; + cancelUpload: (uploadId: string | null) => Promise; +} + +// The type-only imports above are erased, so nothing native loads until this runs. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const loadUploader = (): UploaderModule => require('@/lib/records/uploads'); + +export interface PendingRecordDraft { + /** Client id until the server issues one; also the offline queue key. */ + clientRecordId: string; + recordId: string | null; + definitionKey: string; + definitionVersion: number; + name: string; + values: RecordValueInput[]; + callId?: number | null; + stationGroupId?: number | null; + rowVersion?: number | null; + updatedOn: string; + /** Set when the last push failed; the person is told rather than the write being replayed. */ + lastError?: string | null; + conflict?: FieldRecordConflictKind | null; +} + +interface RecordsState { + preflight: FieldRecordPreflightData | null; + catalog: FieldRecordCatalogData | null; + assignments: FieldRecordAssignmentData[]; + recent: RecordSummaryData[]; + drafts: RecordSummaryData[]; + pendingDrafts: Record; + /** Attachments this device is carrying; an interrupted upload resumes from the server's own count. */ + pendingUploads: Record; + /** Bytes sent per upload, kept out of the persisted slice because it is only meaningful live. */ + uploadProgress: Record; + schemas: Record; + scopeStamp: string | null; + lastSyncTimestampMs: number; + isLoading: boolean; + isSyncing: boolean; + error: string | null; + context: FieldRecordContextInput; + + setContext: (context: FieldRecordContextInput) => void; + runPreflight: () => Promise; + fetchCatalog: () => Promise; + sync: (options?: { full?: boolean }) => Promise; + fetchSchema: (definitionKey: string, version: number) => Promise; + prefill: (definitionKey: string, version: number) => Promise; + + stageDraft: (draft: PendingRecordDraft) => void; + discardDraft: (clientRecordId: string) => void; + pushDraft: (clientRecordId: string) => Promise<{ ok: boolean; recordId?: string; conflict?: FieldRecordConflictKind; error?: string }>; + pushAllDrafts: () => Promise; + submitForReview: (recordId: string, rowVersion: number) => Promise<{ ok: boolean; error?: string }>; + finalize: (recordId: string, rowVersion: number, attested: boolean) => Promise<{ ok: boolean; error?: string }>; + + stageUpload: (upload: PendingUpload) => void; + discardUpload: (localId: string) => Promise; + retryUpload: (localId: string) => Promise; + runUploads: (recordId?: string) => Promise<{ uploaded: number; failed: number }>; + + report: (event: FieldRecordTelemetryEvent) => void; + flushTelemetry: () => Promise; + + acknowledge: (assignmentId: string, rowVersion: number) => Promise; + complete: (assignmentId: string, rowVersion: number) => Promise; + + entryFor: (definitionKey: string, version?: number) => FieldRecordCatalogEntry | null; + reset: () => void; +} + +const catalogInput = (context: FieldRecordContextInput) => ({ + OriginClient: RECORDS_ORIGIN_CLIENT as number, + AppVersion: Env.VERSION, + ClientCapability: CLIENT_CAPABILITY, + Context: context, +}); + +/** An axios failure carries the server's problem type; that is what tells a conflict apart. */ +const conflictFrom = (error: unknown): FieldRecordConflictKind | undefined => { + const response = (error as { response?: { status?: number; data?: { type?: string } } })?.response; + if (!response) { + return undefined; + } + const type = response.data?.type ?? ''; + if (type.includes('concurrency') || type.includes('conflict') || response.status === 412) { + return 'etag'; + } + if (response.status === 403) { + return type.includes('field_records_disabled') ? 'app-version' : 'permission'; + } + if (type.includes('definition')) { + return 'definition-retired'; + } + if (type.includes('protected')) { + return 'protected-data'; + } + if (response.status === 409) { + return 'etag'; + } + return undefined; +}; + +const messageFrom = (error: unknown): string => { + const response = (error as { response?: { data?: { title?: string } } })?.response; + return response?.data?.title ?? (error instanceof Error ? error.message : 'Request failed'); +}; + +export const useRecordsStore = create()( + persist( + (set, get) => ({ + preflight: null, + catalog: null, + assignments: [], + recent: [], + drafts: [], + pendingDrafts: {}, + pendingUploads: {}, + uploadProgress: {}, + schemas: {}, + scopeStamp: null, + lastSyncTimestampMs: 0, + isLoading: false, + isSyncing: false, + error: null, + context: {}, + + setContext: (context) => { + const current = get().context; + if (current.CallId === context.CallId && current.UnitId === context.UnitId && current.GroupId === context.GroupId && current.CommandRole === context.CommandRole) { + return; + } + // The catalog is context-specific, so it is dropped rather than shown against a new context. + set({ context, catalog: null }); + }, + + runPreflight: async () => { + try { + const response = await getFieldRecordsPreflight(RECORDS_ORIGIN_CLIENT, Env.VERSION, CLIENT_CAPABILITY); + const preflight = response?.Data ?? null; + set({ preflight, error: null }); + return preflight; + } catch (error) { + logger.error({ message: 'Field Records preflight failed', context: { error } }); + set({ error: messageFrom(error) }); + return null; + } + }, + + fetchCatalog: async () => { + set({ isLoading: true }); + try { + const response = await getFieldRecordsCatalog(catalogInput(get().context)); + const catalog = response?.Data ?? null; + set({ catalog, scopeStamp: catalog?.ScopeStamp ?? get().scopeStamp, error: null }); + return catalog; + } catch (error) { + logger.error({ message: 'Field Records catalog failed', context: { error } }); + set({ error: messageFrom(error) }); + return null; + } finally { + set({ isLoading: false }); + } + }, + + sync: async (options) => { + if (get().isSyncing) { + return; + } + set({ isSyncing: true }); + const full = options?.full === true; + // A reset answers with no page, so the full re-pull is run after this one releases the + // in-flight guard; recursing inside it would be swallowed by that same guard. + let resetAndRetry = false; + try { + const response = await syncFieldRecords({ + ...catalogInput(get().context), + Since: full ? 0 : get().lastSyncTimestampMs, + ScopeStamp: full ? null : get().scopeStamp, + Take: SYNC_TAKE, + IncludeCatalog: true, + }); + const bundle = response?.Data; + if (!bundle) { + return; + } + + if (bundle.ResetRequired) { + // Scope changed under us: everything cached for the old scope is dropped, including the + // catalog. Staged drafts survive — they are this person's unsent work, not server state. + set({ + catalog: bundle.Catalog ?? null, + recent: [], + drafts: [], + assignments: [], + scopeStamp: bundle.ScopeStamp ?? null, + lastSyncTimestampMs: 0, + }); + // Returning here would skip the retry below, since finally runs but the function exits. + resetAndRetry = !full; + } else { + const tombstoned = new Set(bundle.Tombstones ?? []); + const merged = new Map(); + for (const record of get().recent) { + if (!tombstoned.has(record.RecordId)) { + merged.set(record.RecordId, record); + } + } + for (const record of bundle.Records ?? []) { + if (record.IsTombstone || tombstoned.has(record.RecordId)) { + merged.delete(record.RecordId); + continue; + } + merged.set(record.RecordId, record); + } + + set({ + catalog: bundle.Catalog ?? get().catalog, + recent: [...merged.values()].sort((a, b) => (b.ModifiedOn ?? '').localeCompare(a.ModifiedOn ?? '')).slice(0, SYNC_TAKE), + drafts: bundle.Drafts ?? [], + assignments: bundle.Assignments ?? [], + scopeStamp: bundle.ScopeStamp ?? get().scopeStamp, + lastSyncTimestampMs: bundle.ServerTimestampMs > 0 ? bundle.ServerTimestampMs : get().lastSyncTimestampMs, + error: bundle.Ok ? null : (bundle.Reasons ?? []).join(', '), + }); + } + } catch (error) { + logger.error({ message: 'Field Records sync failed', context: { error } }); + get().report({ EventType: 'sync', Outcome: 'failed' }); + set({ error: messageFrom(error) }); + } finally { + set({ isSyncing: false }); + } + + if (resetAndRetry) { + await get().sync({ full: true }); + } + }, + + fetchSchema: async (definitionKey, version) => { + const key = `${definitionKey}:${version}`; + const cached = get().schemas[key]; + if (cached) { + return cached; + } + try { + const response = await getRecordDefinitionVersion(definitionKey, version); + const schema = response?.Data?.Schema ?? null; + if (schema) { + set({ schemas: { ...get().schemas, [key]: schema } }); + } + return schema; + } catch (error) { + logger.error({ message: 'Record definition schema fetch failed', context: { error, definitionKey, version } }); + return null; + } + }, + + prefill: async (definitionKey, version) => { + try { + const response = await getFieldRecordPrefill({ ...catalogInput(get().context), DefinitionKey: definitionKey, Version: version }); + return response?.Data ?? null; + } catch (error) { + // Prefill is a convenience; a refusal leaves an empty form rather than blocking authoring. + logger.warn({ message: 'Record prefill unavailable', context: { error, definitionKey, version } }); + return null; + } + }, + + stageDraft: (draft) => { + const entry = get().entryFor(draft.definitionKey, draft.definitionVersion); + if (entry && !canAuthorOffline(entry)) { + // A definition that seals values never leaves plaintext on the device, so it is not staged. + logger.info({ message: 'Draft not staged offline: definition requires a live protected-data grant', context: { definitionKey: draft.definitionKey } }); + return; + } + set({ pendingDrafts: { ...get().pendingDrafts, [draft.clientRecordId]: { ...draft, updatedOn: new Date().toISOString() } } }); + }, + + discardDraft: (clientRecordId) => { + const pending = { ...get().pendingDrafts }; + delete pending[clientRecordId]; + set({ pendingDrafts: pending }); + }, + + pushDraft: async (clientRecordId) => { + const draft = get().pendingDrafts[clientRecordId]; + if (!draft) { + return { ok: false, error: 'not_found' }; + } + try { + const result = draft.recordId + ? await saveRecordDraft({ + RecordId: draft.recordId, + RowVersion: draft.rowVersion ?? null, + Values: draft.values, + IdempotencyKey: clientRecordId, + OriginClient: RECORDS_ORIGIN_CLIENT, + }) + : await createRecordDraft({ + DefinitionKey: draft.definitionKey, + CallId: draft.callId ?? null, + StationGroupId: draft.stationGroupId ?? null, + Values: draft.values, + IdempotencyKey: clientRecordId, + ClientRecordId: clientRecordId, + OriginClient: RECORDS_ORIGIN_CLIENT, + }); + + const recordId = result?.Data?.RecordId; + if (!recordId) { + throw new Error('The server did not return a record.'); + } + get().discardDraft(clientRecordId); + get().report({ EventType: 'draft_saved', Outcome: 'ok', RecordId: recordId, DefinitionKey: draft.definitionKey, DefinitionVersion: draft.definitionVersion }); + void get().sync(); + return { ok: true, recordId }; + } catch (error) { + const conflict = conflictFrom(error); + // Never replayed silently: the draft is kept and flagged so a person decides what happens. + set({ + pendingDrafts: { + ...get().pendingDrafts, + [clientRecordId]: { ...draft, lastError: messageFrom(error), conflict: conflict ?? null }, + }, + }); + logger.error({ message: 'Record draft push failed', context: { error, clientRecordId, conflict } }); + get().report({ EventType: 'draft_saved', Outcome: conflict ?? 'failed', DefinitionKey: draft.definitionKey, DefinitionVersion: draft.definitionVersion }); + if (conflict) { + get().report({ EventType: 'conflict', Outcome: conflict, DefinitionKey: draft.definitionKey }); + } + return { ok: false, conflict, error: messageFrom(error) }; + } + }, + + pushAllDrafts: async () => { + for (const clientRecordId of Object.keys(get().pendingDrafts)) { + const draft = get().pendingDrafts[clientRecordId]; + if (draft?.conflict) { + // A conflicted draft waits for a person; retrying it in a loop would be the silent replay. + continue; + } + await get().pushDraft(clientRecordId); + } + }, + + submitForReview: async (recordId, rowVersion) => { + try { + await submitRecordForReview({ RecordId: recordId, RowVersion: rowVersion, OriginClient: RECORDS_ORIGIN_CLIENT, IdempotencyKey: `submit:${recordId}:${rowVersion}` }); + get().report({ EventType: 'completed', Outcome: 'submitted', RecordId: recordId }); + void get().sync(); + return { ok: true }; + } catch (error) { + logger.error({ message: 'Submit for review failed', context: { error, recordId } }); + return { ok: false, error: messageFrom(error) }; + } + }, + + finalize: async (recordId, rowVersion, attested) => { + try { + await finalizeRecord({ RecordId: recordId, RowVersion: rowVersion, Attested: attested, OriginClient: RECORDS_ORIGIN_CLIENT, IdempotencyKey: `finalize:${recordId}:${rowVersion}` }); + get().report({ EventType: 'completed', Outcome: 'finalized', RecordId: recordId }); + void get().sync(); + return { ok: true }; + } catch (error) { + logger.error({ message: 'Finalize failed', context: { error, recordId } }); + return { ok: false, error: messageFrom(error) }; + } + }, + + stageUpload: (upload) => { + set({ pendingUploads: { ...get().pendingUploads, [upload.localId]: upload }, uploadProgress: { ...get().uploadProgress, [upload.localId]: upload.sentBytes } }); + }, + + discardUpload: async (localId) => { + const upload = get().pendingUploads[localId]; + // Hand the session back so its chunks are not left occupying server storage. + const { cancelUpload } = loadUploader(); + await cancelUpload(upload?.uploadId ?? null); + const uploads = { ...get().pendingUploads }; + const progress = { ...get().uploadProgress }; + delete uploads[localId]; + delete progress[localId]; + set({ pendingUploads: uploads, uploadProgress: progress }); + }, + + retryUpload: async (localId) => { + const upload = get().pendingUploads[localId]; + if (!upload || upload.isUnrecoverable) { + return; + } + set({ pendingUploads: { ...get().pendingUploads, [localId]: { ...upload, lastError: null } } }); + await get().runUploads(upload.recordId); + }, + + runUploads: async (recordId) => { + const uploads = Object.values(get().pendingUploads).filter((upload) => (recordId ? upload.recordId === recordId : true)); + let uploaded = 0; + let failed = 0; + if (uploads.length === 0) { + return { uploaded, failed }; + } + const { runUpload } = loadUploader(); + + for (const upload of uploads) { + // A file that is gone cannot be uploaded by trying again; it waits for the person instead. + if (upload.isUnrecoverable) { + continue; + } + const startedAt = Date.now(); + const outcome = await runUpload(upload, { + onProgress: ({ sentBytes }) => set({ uploadProgress: { ...get().uploadProgress, [upload.localId]: sentBytes } }), + }); + + if (outcome.ok) { + uploaded += 1; + get().report({ EventType: 'attachment', Outcome: 'ok', RecordId: upload.recordId, DurationMs: Date.now() - startedAt, ItemCount: Math.round(upload.byteSize / 1024) }); + const remaining = { ...get().pendingUploads }; + const progress = { ...get().uploadProgress }; + delete remaining[upload.localId]; + delete progress[upload.localId]; + set({ pendingUploads: remaining, uploadProgress: progress }); + continue; + } + + failed += 1; + get().report({ EventType: 'attachment', Outcome: outcome.code ?? 'failed', RecordId: upload.recordId }); + set({ + pendingUploads: { + ...get().pendingUploads, + [upload.localId]: { + ...upload, + uploadId: outcome.uploadId ?? upload.uploadId, + sentBytes: outcome.sentBytes ?? upload.sentBytes, + lastError: outcome.message ?? outcome.code ?? 'failed', + isUnrecoverable: outcome.code === 'file_missing', + }, + }, + }); + } + + return { uploaded, failed }; + }, + + report: (event) => { + // Telemetry is bounded and best-effort: it queues, it batches, and it is dropped rather than + // grown without limit. Nothing here carries record content — codes, counts and durations only. + const queued = [...telemetryQueue, { ...event, OccurredOn: event.OccurredOn ?? new Date().toISOString() }]; + telemetryQueue = queued.slice(-TELEMETRY_MAX); + }, + + flushTelemetry: async () => { + if (telemetryQueue.length === 0) { + return; + } + const batch = telemetryQueue; + telemetryQueue = []; + try { + await reportFieldRecordTelemetry({ + OriginClient: RECORDS_ORIGIN_CLIENT as number, + AppVersion: Env.VERSION, + ClientCapability: CLIENT_CAPABILITY, + Events: batch, + }); + } catch (error) { + // A rollout number is never worth a retry storm; the batch is dropped and the next one goes. + logger.info({ message: 'Field Records telemetry batch was dropped', context: { count: batch.length } }); + } + }, + + acknowledge: async (assignmentId, rowVersion) => { + try { + const response = await acknowledgeAssignmentApi({ AssignmentId: assignmentId, RowVersion: rowVersion, Context: get().context, OriginClient: RECORDS_ORIGIN_CLIENT }); + const updated = response?.Data; + if (updated) { + set({ assignments: get().assignments.map((assignment) => (assignment.AssignmentId === updated.AssignmentId ? updated : assignment)) }); + } + } catch (error) { + logger.error({ message: 'Acknowledge assignment failed', context: { error, assignmentId } }); + set({ error: messageFrom(error) }); + } + }, + + complete: async (assignmentId, rowVersion) => { + try { + await completeAssignmentApi({ AssignmentId: assignmentId, RowVersion: rowVersion, Context: get().context, OriginClient: RECORDS_ORIGIN_CLIENT }); + set({ assignments: get().assignments.filter((assignment) => assignment.AssignmentId !== assignmentId) }); + } catch (error) { + logger.error({ message: 'Complete assignment failed', context: { error, assignmentId } }); + set({ error: messageFrom(error) }); + } + }, + + entryFor: (definitionKey, version) => { + const definitions = get().catalog?.Definitions ?? []; + return definitions.find((entry) => entry.DefinitionKey.toLowerCase() === definitionKey.toLowerCase() && (version === undefined || entry.Version === version)) ?? null; + }, + + reset: () => { + // Queued telemetry belongs to the identity that produced it; a reset drops it rather than + // letting the next session flush another member's counts under their own name. + telemetryQueue = []; + set({ + preflight: null, + catalog: null, + assignments: [], + recent: [], + drafts: [], + pendingDrafts: {}, + pendingUploads: {}, + uploadProgress: {}, + schemas: {}, + scopeStamp: null, + lastSyncTimestampMs: 0, + error: null, + context: {}, + }); + }, + }), + { + name: 'records-field-storage', + storage: createJSONStorage(() => zustandStorage), + // Server-owned lists are re-fetched on every open; only the device's own unsent work, the sync + // cursor and the scope it belongs to are worth keeping across launches. + partialize: (state) => ({ + pendingDrafts: state.pendingDrafts, + // Pending uploads persist so an app killed mid-upload resumes from the server's count. + pendingUploads: state.pendingUploads, + scopeStamp: state.scopeStamp, + lastSyncTimestampMs: state.lastSyncTimestampMs, + }), + } + ) +); + +export const useRecordsDraftCount = () => useRecordsStore((state) => Object.keys(state.pendingDrafts).length); + +export const useOpenAssignments = () => useRecordsStore((state) => state.assignments.filter((assignment) => assignment.State === 'Open' || assignment.State === 'Acknowledged')); + +export const useReturnedRecords = () => useRecordsStore((state) => state.drafts.filter((record) => record.State === RmsRecordState.Returned)); + +export const useOwnDrafts = () => useRecordsStore((state) => state.drafts.filter((record) => record.State === RmsRecordState.Draft)); + +export const buildValuesPayload = (values: ValueMap): RecordValueInput[] => toValueList(values); diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index 1e66bd1d..50a3124a 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -219,6 +219,7 @@ function scheduleCallsRefresh(): void { /** Update-hub events that carry a per-event timestamp for targeted refetches. */ export const UPDATE_HUB_EVENTS = [ + 'checklistUpdated', 'personnelStatusUpdated', 'personnelStaffingUpdated', 'unitStatusUpdated', diff --git a/src/stores/status/__tests__/store.test.ts b/src/stores/status/__tests__/store.test.ts index 61f3ded1..c1fd4f97 100644 --- a/src/stores/status/__tests__/store.test.ts +++ b/src/stores/status/__tests__/store.test.ts @@ -342,13 +342,49 @@ describe('StatusesStore', () => { 'call1', null, [{ roleId: 'role1', userId: 'user1' }], - undefined + undefined, + expect.any(Date) ); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBe(null); }); + it('should queue the original status time, not the time the network request gave up', async () => { + const { result } = renderHook(() => useStatusesStore()); + + let sentTimestamp = ''; + mockSaveUnitStatus.mockImplementationOnce(async (sent: SaveUnitStatusInput) => { + sentTimestamp = sent.Timestamp; + // The request hangs until the client timeout before failing as a network error + jest.setSystemTime(new Date(Date.now() + 30000)); + throw new Error('Network error'); + }); + mockOfflineEventManager.queueUnitStatusEvent.mockReturnValue('queued-event-id'); + + const input = new SaveUnitStatusInput(); + input.Id = 'unit1'; + input.Type = '1'; + input.RespondingTo = '555'; + input.RespondingToType = 2; + + jest.useFakeTimers({ now: new Date('2026-09-23T10:00:00.000Z') }); + try { + await act(async () => { + await result.current.saveUnitStatus(input); + }); + } finally { + jest.useRealTimers(); + } + + expect(sentTimestamp).toBe('2026-09-23T10:00:00.000Z'); + const recordedAt = mockOfflineEventManager.queueUnitStatusEvent.mock.calls[0][7] as Date; + expect(recordedAt.toISOString()).toBe('2026-09-23T10:00:00.000Z'); + // The destination rides along into the queue + expect(mockOfflineEventManager.queueUnitStatusEvent.mock.calls[0][3]).toBe('555'); + expect(mockOfflineEventManager.queueUnitStatusEvent.mock.calls[0][4]).toBe(2); + }); + it('should handle successful save and refresh active unit', async () => { const { result } = renderHook(() => useStatusesStore()); @@ -425,10 +461,11 @@ describe('StatusesStore', () => { 'unit1', '1', '', // Note defaults to empty string - '', // RespondingTo defaults to empty string + '', // RespondingTo defaults to empty string null, [], // Roles defaults to empty array which maps to empty array - undefined + undefined, + expect.any(Date) ); }); diff --git a/src/stores/status/store.ts b/src/stores/status/store.ts index 1ecd7dc8..2ac11787 100644 --- a/src/stores/status/store.ts +++ b/src/stores/status/store.ts @@ -204,6 +204,8 @@ export const useStatusesStore = create((set) => ({ set({ isLoading: true, error: null }); try { + // The moment the crew set the status. It is also what the offline queue replays, so a status + // queued while offline keeps its real time on the unit's call timeline, not the drain time. const date = new Date(); input.Timestamp = date.toISOString(); input.TimestampUtc = date.toUTCString().replace('UTC', 'GMT'); @@ -314,7 +316,9 @@ export const useStatusesStore = create((set) => ({ } } - const eventId = offlineEventManager.queueUnitStatusEvent(input.Id, input.Type, input.Note || '', input.RespondingTo || '', input.RespondingToType, roles, gpsData); + // Pass the original status time: the failed request above can take up to the client + // timeout, and stamping at queue time would shift the status by that much. + const eventId = offlineEventManager.queueUnitStatusEvent(input.Id, input.Type, input.Note || '', input.RespondingTo || '', input.RespondingToType, roles, gpsData, date); logger.info({ message: 'Unit status queued for offline processing', diff --git a/src/translations/ar.json b/src/translations/ar.json index 3e867ba1..e658691b 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -126,6 +126,12 @@ "title": "ملاحظات المكالمة" }, "call_detail": { + "activity_link": { + "auto": "مرتبط تلقائيًا", + "auto_hint": "أُرسلت دون مكالمة؛ ربطها Resgrid من الحالة السابقة أو من الإرسال.", + "inferred": "مُستنتَج", + "inferred_hint": "عيّنتها دون وجهة وحدةٌ أو شخصٌ مُرسَل إلى هذه المكالمة أثناء العمل عليها." + }, "address": "العنوان", "call_location": "موقع المكالمة", "close_call": "إغلاق المكالمة", @@ -201,6 +207,7 @@ "dispatched": "المرسلة", "info": "المعلومات", "protocols": "البروتوكولات", + "site": "معلومات الموقع", "timeline": "النشاط" }, "timestamp": "الطابع الزمني", @@ -228,6 +235,9 @@ "contact_name_placeholder": "أدخل اسم جهة الاتصال", "contact_phone": "هاتف جهة الاتصال", "contact_phone_placeholder": "أدخل هاتف جهة الاتصال", + "contact_picker": { + "linked": "جهة الاتصال المرتبطة: {{name}}" + }, "coordinates": "إحداثيات GPS", "coordinates_found": "تم العثور على الإحداثيات وتحديث العنوان", "coordinates_geocoding_error": "فشل في الحصول على العنوان للإحداثيات، لكن تم تعيين الموقع على الخريطة", @@ -445,6 +455,206 @@ "type_sector_rotation": "تناوب القطاع", "type_unit": "الوحدة" }, + "checklists": { + "errors": { + "conflict": "تم تغيير هذا الفحص على الخادم. تم الاحتفاظ بعملك المحلي؛ راجعه قبل إعادة المحاولة.", + "denied": "الوصول غير متاح. تم الاحتفاظ بعملك المحلي المشفّر.", + "locked": "افتح البيانات المحمية للمتابعة.", + "offline": "دون اتصال: يتم عرض الفحوص المحفوظة بأمان.", + "retry": "تعذر إكمال المزامنة. تم الاحتفاظ بعملك المحلي.", + "storage_full": "تم بلوغ الحد المحلي للمسودات أو الأدلة.", + "validation": "تحقق من الإجابات المطلوبة والتواريخ والملاحظات والأدلة، ثم أعد المحاولة." + }, + "labels": { + "Actions": "الإجراءات", + "Active": "نشطة", + "AnchorMonth": "الشهر المرجعي", + "Annual": "سنويًا", + "Answer": "الإجابة", + "Answered": "تمت الإجابة", + "AssetUnavailable": "المعدات ذات الرقم التسلسلي غير متاحة.", + "Assignment": "مُسند إلى", + "AssignmentAutomatic": "إسناد تلقائي", + "AssignmentPermission": "لم يُسند إليك إجراء هذا الفحص.", + "AssignmentType1": "شخص", + "AssignmentType2": "دور وظيفي", + "AssignmentType3": "مجموعة أو محطة", + "AssignmentType4": "طاقم الوحدة", + "AssignmentUnavailable": "اختر إسنادًا متاحًا في هذه المؤسسة.", + "Attest": "إقرار وإكمال", + "Attestation": "إقرار التحقق المستقل", + "Author": "مُعدّ القائمة", + "AwaitingWitness": "بانتظار الشاهد", + "Back": "رجوع", + "CalendarInclude": "فحوص الجاهزية", + "CalendarRange": "اختر فترة تقويم لا تتجاوز 93 يومًا.", + "Cancelled": "ملغى", + "Category": "الفئة", + "Checkbox": "مربع اختيار", + "Checked": "محدد", + "ChecklistCompleted": "اكتملت قائمة التحقق", + "ChecklistFailed": "لم تجتز قائمة التحقق", + "ChecklistMissed": "لم تُنفّذ قائمة التحقق", + "ChecklistOccurrenceSkipped": "تم تبرير تجاوز الفحص المجدول", + "ChecklistScheduleChanged": "تم تغيير جدول قائمة التحقق", + "Checklists": "قوائم التحقق", + "Checks": "الفحوصات", + "Choose": "اختر", + "Critical": "حرج", + "Daily": "يوميًا", + "Date": "التاريخ", + "DayOfMonth": "اليوم من الشهر", + "DeleteDraft": "حذف المسودة", + "Department": "الإدارة", + "Draft": "مسودة", + "DraftGuidance": "احفظ مسودة ثم انشرها. تحتفظ عمليات التحقق الحالية بإصدارها المنشور الأصلي. لا تُدخل بيانات المرضى.", + "DueChecks": "الفحوص المستحقة", + "DueGuidance": "الفحوص القادمة لسبعة أيام والفحوص غير المكتملة. لا يزال بالإمكان إكمال الفحوص الفائتة.", + "EditChecklist": "محرر قائمة التحقق", + "EditDraft": "تعديل المسودة", + "EditSchedule": "تعديل الجدول", + "Evidence": "المرفقات الإثباتية", + "ExcuseCheck": "تبرير تجاوز الفحص", + "Export": "تصدير بصيغة JSON", + "Facility": "المنشأة", + "Fail": "لم يجتز", + "FailNote": "يلزم إدخال ملاحظة عند عدم اجتياز الفحص.", + "Failed": "لم يجتز الفحص", + "Free": "قوائم التحقق مجانية لجميع الإدارات.", + "Frequency": "معدل التكرار", + "Guidance": "قبل الاستخدام، عدّل هذه القوالب لتناسب معداتك وإجراءات الشركة المصنّعة والمتطلبات المحلية.", + "History": "السجل", + "InProgress": "قيد التنفيذ", + "Incomplete": "غير مكتمل", + "InventoryAsset": "معدات ذات رقم تسلسلي", + "Item": "البند", + "Maintenance": "الصيانة وأوامر العمل (Readiness Pro)", + "Missed": "لم يُنفّذ", + "MonthGuidance": "للفحوص الشهرية أو الأقل تكرارًا. تستخدم الأشهر القصيرة يومها الأخير. يحدد الشهر المرجعي الفحوص ربع السنوية ونصف السنوية والسنوية.", + "Monthly": "شهريًا", + "Name": "الاسم", + "NewChecklist": "قائمة تحقق جديدة", + "NewSchedule": "جدول جديد", + "Next": "التالي", + "No": "لا", + "NoDefinitions": "لا توجد قوائم تحقق في هذه الصفحة.", + "NoDueChecks": "لا توجد فحوص في هذه القائمة.", + "NoResults": "لا توجد قوالب تطابق بحثك.", + "NoSchedules": "لا توجد جداول بعد.", + "NoScore": "لا توجد درجة قابلة للاحتساب", + "Note": "الملاحظة", + "OccurrenceUnavailable": "هذا الفحص غير متاح أو تم تغييره بالفعل.", + "OpenCheck": "فتح الفحص", + "Optional": "اختياري", + "Other": "أخرى", + "Pass": "اجتاز", + "Passed": "اجتاز الفحص", + "PerShift": "كل وردية", + "Personnel": "الأفراد", + "Photo": "صورة", + "Preview": "معاينة القالب", + "Previous": "السابق", + "Print": "طباعة", + "PublishDraft": "نشر المسودة", + "PublishGuidance": "ينشئ النشر إصدارًا غير قابل للتعديل لعمليات التحقق الجديدة.", + "Published": "منشورة", + "PublishedVersion": "الإصدار المنشور", + "Quantity": "الكمية", + "Quarterly": "كل ثلاثة أشهر", + "ReminderBefore": "الدقائق قبل الموعد النهائي (0 للتعطيل)", + "ReminderDigest": "جمع التذكيرات المعلقة في ملخصات", + "ReminderEnabled": "تفعيل تذكيرات قوائم التحقق", + "ReminderEscalate": "إشعار مسؤولي القسم بعد هذا العدد من دقائق التأخير (اتركه فارغًا للتعطيل)", + "ReminderFixedTime": "وقت الملخص اليومي (المنطقة الزمنية للمؤسسة؛ يحل محل بداية المناوبة)", + "ReminderHelp": "تنطبق التذكيرات على عمليات التحقق التي تبدأ بعد تفعيلها. تحتوي الرسائل على رابط لتسجيل الدخول وتراعي تفضيلات الإشعارات لكل شخص. تجمع الملخصات التذكيرات المعلقة حسب المستلم.", + "ReminderMessage": "تتوفر تحديثات لقوائم التحقق. سجّل الدخول لمراجعة عمليات التحقق الخاصة بك.", + "ReminderMissed": "إشعار الموظفين المسؤولين بعمليات التحقق الفائتة", + "ReminderSettings": "تذكيرات قوائم التحقق", + "ReminderShiftStart": "إرسال ملخص عند بداية المناوبة", + "ReminderTimeValidation": "أدخل وقتًا واحدًا بتنسيق HH:mm.", + "ReminderTitle": "تذكيرات قوائم التحقق", + "ReminderValidation": "أدخل مدة للتذكير من 0 إلى 10080 دقيقة ومدة لإشعار المسؤولين من 1 إلى 43200 دقيقة، أو اترك مدة إشعار المسؤولين فارغة.", + "Remove": "حذف", + "ReportedLocation": "الموقع المُبلّغ عنه", + "Required": "مطلوب", + "Result": "النتيجة", + "Retire": "إيقاف الاستخدام", + "Retired": "متوقفة عن الاستخدام", + "RunGuidance": "أجب عن كل بند ينطبق على الحالة. يتطلب اختيار «لا ينطبق» ذكر السبب. احفظ التقدم للمتابعة لاحقًا. لا يمكن تعديل الإجابات والمرفقات بعد إرسالها.", + "SaveDraft": "حفظ المسودة", + "SaveProgress": "حفظ التقدم", + "SaveReminders": "حفظ إعدادات التذكيرات", + "SaveSchedule": "حفظ الجدول", + "ScheduleEnabled": "الجدول مفعّل", + "ScheduleEndDate": "تاريخ الانتهاء (اختياري)", + "ScheduleNotes": "ملاحظات الجدول", + "SchedulePaused": "متوقف مؤقتًا", + "SchedulePermission": "ليس لديك إذن لإدارة جداول الفحص.", + "ScheduleRequiresPublished": "انشر قائمة التحقق قبل جدولتها.", + "ScheduleStartDate": "تاريخ البدء", + "ScheduleTimeGuidance": "تتبع الأوقات هذه المنطقة. يُنقل الوقت غير الموجود إلى الأمام؛ ويحدث الوقت المكرر مرة واحدة.", + "ScheduleUnavailable": "هذا الجدول غير متاح.", + "ScheduleValidation": "تحقق من تواريخ الجدول وأوقاته والهدف وإعدادات التكرار.", + "ScheduleVersionGuidance": "يحفظ الجدول الإصدار المنشور الحالي. تستبدل التغييرات الفحوص المستقبلية التي لم تبدأ؛ ويُلغي الإيقاف المؤقت الفحوص التي لم تبدأ.", + "ScheduleWorkshift": "وردية العمل للفحوص في كل وردية", + "Scheduled": "مجدول", + "ScheduledFor": "موعد الفحص", + "Schedules": "الجداول", + "Score": "الدرجة", + "Search": "بحث", + "SearchLabel": "البحث عن قالب", + "SelectTarget": "اختر الجهة أو العنصر محل الفحص", + "SemiAnnual": "كل ستة أشهر", + "Signature": "توقيع", + "SkipReason": "سبب التجاوز المبرر للفحص", + "SkipReasonRequired": "أدخل سببًا لا يتجاوز 4000 حرف.", + "Skipped": "تجاوز مبرر", + "StartRun": "بدء قائمة التحقق", + "Started": "وقت البدء", + "State": "الحالة", + "SubmitRun": "إرسال قائمة التحقق", + "Submitted": "تم الإرسال", + "Target": "الجهة أو العنصر محل الفحص", + "Templates": "قوالب قوائم التحقق", + "TimeZone": "المنطقة الزمنية", + "TimesOfDay": "الأوقات المحلية", + "TimesOfDayGuidance": "أدخل ما يصل إلى ثمانية أوقات بتنسيق 24 ساعة، مفصولة بفواصل إنجليزية.", + "Unanswered": "لم تتم الإجابة", + "Unchecked": "غير محدد", + "Unit": "الوحدة", + "Units": "وحدات القياس", + "Version": "الإصدار", + "Weekly": "أسبوعيًا", + "WeeklyDays": "أيام الفحوص الأسبوعية", + "WindowEnds": "نهاية المهلة", + "WindowMinutes": "مهلة الإكمال (بالدقائق)", + "Witness": "يجب على مُعدّ القائمة وعضو آخر، بعد التحقق من هوية كل منهما، التحقق من النتيجة بشكل مستقل.", + "WitnessInstructions": "يجب أن يتحقق عضو آخر، بعد التحقق من هويته، من الإجابات والمرفقات المُرسلة بشكل مستقل. شارك معه رابط هذه الصفحة.", + "WitnessLabel": "الشاهد", + "WorkshiftFallback": "استخدام الأوقات المحلية كل يوم", + "WorkshiftGuidance": "للفحوص في كل وردية فقط. تستخدم الوردية المختارة تواريخ البدء المسجلة؛ وإلا فتُستخدم الأوقات المحلية يوميًا.", + "Yes": "نعم" + }, + "ui": { + "calendar": "التقويم", + "clearSignature": "مسح التوقيع", + "close": "إغلاق", + "confirm": "استبدال", + "editLocal": "تحرير المسودة المحلية", + "localDrafts": "المسودات المحلية", + "notApplicable": "لا ينطبق", + "prepareOffline": "التحضير للاستخدام دون اتصال", + "queued": "تم الحفظ بأمان على هذا الجهاز؛ في انتظار المزامنة.", + "reason": "السبب", + "refresh": "تحديث", + "removeImage": "إزالة الصورة", + "replaceLocal": "هل تريد استبدال الإجابات المحلية والصور المعلّقة بنسخة الخادم؟", + "retry": "إعادة محاولة المزامنة", + "takePhoto": "التقاط صورة", + "unlock": "فتح البيانات المحمية", + "useServer": "استخدام نسخة الخادم" + } + }, "common": { "add": "إضافة", "back": "رجوع", @@ -536,6 +746,12 @@ "expires": "تنتهي في", "facebook": "فيسبوك", "faxPhone": "هاتف الفاكس", + "files": { + "download_failed": "تعذر تنزيل الملف.", + "empty": "لم يتم إرفاق أي ملفات بهذه الجهة.", + "loading": "جارٍ تحميل الملفات...", + "share_unavailable": "المشاركة غير متاحة على هذا الجهاز." + }, "formError": "يرجى إصلاح الأخطاء في النموذج", "homePhone": "هاتف المنزل", "identification": "التعريف", @@ -557,6 +773,48 @@ "otherInfo": "معلومات أخرى", "person": "شخص", "phone": "هاتف", + "preplan": { + "access": "الوصول", + "access_notes": "ملاحظات الوصول", + "alarm_company": "شركة الإنذار", + "alarm_company_phone": "هاتف شركة الإنذار", + "alarm_panel": "لوحة الإنذار", + "alert_flag": "تنبيه", + "construction": "البناء · السقف · الإشغال", + "electric_shutoff": "قطع الكهرباء", + "emergency_contact": "جهة اتصال الطوارئ", + "emergency_contact_phone": "هاتف جهة اتصال الطوارئ", + "gas_shutoff": "قطع الغاز", + "gate_code": "رمز البوابة", + "general_hazard_notes": "ملاحظات عامة عن المخاطر", + "general_hazards": "المخاطر والتكتيكات", + "hazards": "مخاطر الموقع", + "hazmat_on_site": "مواد خطرة في الموقع", + "knox_box": "صندوق المفاتيح", + "last_reviewed": "آخر مراجعة", + "loading": "جارٍ تحميل الخطة المسبقة...", + "location": "الموقع", + "nearest_hydrant": "أقرب صنبور إطفاء", + "never_reviewed": "لم تتم مراجعتها", + "next_review": "المراجعة التالية", + "none": "لم يتم تسجيل خطة مسبقة لهذه الجهة.", + "occupancy": "البناء والإشغال", + "occupancy_hours": "ساعات الإشغال", + "occupancy_notes": "ملاحظات الإشغال", + "occupant_load": "عدد الشاغلين", + "occupants_needing_assistance": "قد يحتاج الشاغلون إلى مساعدة في الإخلاء", + "on_site_contacts": "جهات الاتصال في الموقع", + "required_fire_flow": "التدفق المطلوب", + "review_overdue": "انقضى موعد مراجعة الخطة المسبقة.", + "secondary_contact": "جهة اتصال ثانوية", + "secondary_contact_phone": "هاتف جهة الاتصال الثانوية", + "tactical_summary": "الملخص التكتيكي", + "utilities": "قطع المرافق", + "utility_notes": "ملاحظات المرافق", + "water_shutoff": "قطع المياه", + "water_supply": "إمدادات المياه", + "water_supply_notes": "ملاحظات إمدادات المياه" + }, "public": "عام", "required": "مطلوب", "save": "حفظ جهة الاتصال", @@ -569,7 +827,9 @@ "systemInformation": "معلومات النظام", "tabs": { "details": "التفاصيل", - "notes": "الملاحظات" + "files": "الملفات", + "notes": "الملاحظات", + "preplan": "الخطة المسبقة" }, "threads": "Threads", "title": "جهات الاتصال", @@ -578,7 +838,13 @@ "unknown_person": "شخص غير معروف", "visibility": "الرؤية", "website": "الموقع الإلكتروني", - "zip": "الرمز البريدي" + "zip": "الرمز البريدي", + "withheld": "تم إخفاء {{count}} من التفاصيل المحمية.", + "categoryLabel": "الفئة: {{name}}", + "physicalAddress": "العنوان الفعلي", + "mailingAddress": "العنوان البريدي", + "hasGeofence": "لهذا الموقع سياج جغرافي مرسوم على الخريطة.", + "customFields": "حقول مخصصة" }, "data_protection": { "conceal": "إخفاؤها مجددًا", @@ -813,11 +1079,193 @@ "select_all": "تحديد الكل", "selected_count": "تم تحديد {{count}}", "title": "الإشعارات", - "unable_to_load": "تعذر تحميل الإشعارات" + "unable_to_load": "تعذر تحميل الإشعارات", + "detail_title": "إشعار", + "additional_info": "معلومات إضافية", + "view_call": "عرض المكالمة", + "view_chat": "عرض المحادثة", + "open_reference": "فتح" }, "onboarding": { "message": "مرحبًا بك في تطبيق Resgrid Unit" }, + "operations": { + "all": "الكل", + "back": "رجوع", + "disabled": "عمليات النشر غير مفعّلة لهذا القسم.", + "entryType": { + "0": "نشر", + "1": "استعداد", + "2": "انتقال" + }, + "errors": { + "denied": "ليس لديك صلاحية الوصول إلى هذا النشر.", + "disabled": "هذه الميزة معطّلة لقسمك.", + "locked": "افتح البيانات المحمية للمتابعة.", + "retry": "تعذر الوصول إلى الخادم. حاول مرة أخرى.", + "validation": "رفض الخادم التغيير.", + "timereports_subject_covered": "لهذا الطاقم أو الشخص وقت مسجل بالفعل في تقرير آخر لهذا اليوم.", + "timereports_date_exists": "يوجد بالفعل تقرير لهذا الطاقم أو الشخص في ذلك اليوم.", + "timereports_scope_invalid": "هذا الطاقم أو الشخص غير نشط في عملية النشر هذه.", + "timereports_locked": "لم يعد بالإمكان تغيير هذا التقرير.", + "timereports_time_invalid": "أدخل الأوقات بصيغة HH:mm.", + "deployments_closed": "عملية النشر هذه مغلقة.", + "expenses_receipt_invalid": "تعذرت قراءة الإيصال.", + "deployments_attachment_too_large": "الإيصال كبير جدًا." + }, + "financeMode": { + "0": "تشغيلي", + "1": "استرداد التكاليف", + "2": "قابل للفوترة" + }, + "issues": { + "end_before_start": "يجب أن يكون وقت الانتهاء بعد وقت البدء.", + "missing_subject": "اختر لمن أو لماذا هذا الإدخال.", + "missing_time": "أدخل وقت البدء والانتهاء.", + "overlap": "يتداخل هذا الإدخال مع إدخال آخر لنفس الشخص أو الوحدة.", + "subject_outside_report_scope": "هذا القيد يخص شخصًا خارج طاقم هذا التقرير أو الشخص المعني.", + "subject_on_other_report": "لهذا الشخص أو الوحدة وقت مسجل بالفعل في تقرير آخر لنفس اليوم.", + "subject_not_on_roster": "هذا القيد يخص شخصًا غير موجود في القائمة.", + "entries_overlap": "يتداخل قيدان للشخص أو الوحدة نفسها.", + "no_entries": "أضف قيدًا واحدًا على الأقل.", + "break_rule": "لا توجد استراحة مسجلة لمناوبة طويلة.", + "long_travel": "وقت السفر أكثر من المسموح في يوم واحد ويحتاج إلى اعتماد.", + "outside_report_date": "هذا القيد يقع خارج تاريخ التقرير." + }, + "mars": { + "counts": "{{errors}} أخطاء · {{warnings}} تحذيرات", + "draft": "إعداد مسودة F-42 من تقارير الوقت", + "f42": "F-42", + "help": "يتم إعداد F-42 من تقارير الوقت المقدمة والتحقق منه وفق قواعد بوابة CAL OES. يبقى التقديم خطوة يدوية مدقّقة على الويب.", + "none": "لا توجد عناصر MARS لهذا النشر بعد.", + "notReady": "غير جاهز للبوابة", + "ready": "جاهز للبوابة", + "record": "MARS {{id}} · {{status}}", + "title": "F-42", + "validate": "تحقق" + }, + "noNumbers": "لا توجد أرقام حادث أو طلب", + "none": "لا توجد عمليات نشر.", + "open": "مفتوحة", + "phase": { + "0": "تعبئة", + "1": "استعداد", + "2": "حادث", + "3": "عودة" + }, + "reportStatus": { + "0": "مسودة", + "1": "مُقدَّم", + "2": "معتمد", + "3": "مفوتر", + "4": "ملغى" + }, + "roster": "{{units}} وحدات · {{personnel}} أفراد", + "status": { + "0": "مخطط", + "1": "استعداد", + "2": "نشط", + "3": "إنهاء التعبئة", + "4": "مكتمل", + "5": "ملغى" + }, + "time": { + "accommodation": "الإقامة مقدمة من الجهة", + "addEntry": "إضافة إدخال", + "from": "من", + "hours": "{{hours}} س", + "meals": "الوجبات مقدمة من الجهة", + "noEntries": "لا توجد إدخالات بعد.", + "noReport": "لا يوجد تقرير وقت لهذا اليوم.", + "notes": "ملاحظات", + "remove": "إزالة", + "report": "التقرير #{{number}}", + "save": "حفظ الإدخالات", + "start": "بدء تقرير الوقت", + "subject": "من أو ماذا", + "submit": "تقديم التقرير", + "title": "الوقت", + "to": "إلى", + "total": "الإجمالي {{hours}} س", + "type": "نوع الإدخال", + "unpaidBreak": "استراحة غير مدفوعة (دقيقة)", + "crewReport": "تقرير وقت الطاقم #{{number}} · {{name}}", + "individualReport": "تقرير الوقت #{{number}} · {{name}}", + "noCrewReport": "لا يوجد تقرير وقت طاقم لـ {{name}} في هذا اليوم.", + "coveredBy": "هذا الوقت مسجل بالفعل في التقرير #{{number}} لهذا اليوم.", + "startCrew": "بدء تقرير وقت الطاقم", + "readOnly": "يمكنك عرض هذا التقرير لكن لا يمكنك تغييره.", + "nextDay": "ينتهي في اليوم التالي", + "applyToCrew": "تطبيق على الطاقم", + "approve": "اعتماد التقرير", + "dayReports": "تقارير هذا اليوم", + "awaitingApproval": "بانتظار الاعتماد ({{count}})", + "notOnRoster": "أنت لست ضمن قائمة أفراد أو طاقم عملية النشر هذه." + }, + "title": "عمليات النشر", + "usage": { + "add": "إضافة قراءة", + "endOdometer": "عداد المسافة النهائي", + "engineHours": "ساعات المحرك", + "fuel": "الوقود", + "needsReview": "يحتاج مراجعة", + "phase": "المرحلة", + "saved": "تم حفظ القراءة.", + "startOdometer": "عداد المسافة الابتدائي", + "title": "الاستخدام", + "unit": "الوحدة" + }, + "crewOf": "طاقم {{names}}", + "previousDay": "اليوم السابق", + "nextDay": "اليوم التالي", + "scope": { + "deployment": "عملية النشر بالكامل", + "mine": "وقتي", + "crew": "الطاقم: {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "التوقيعات", + "crewBossSigned": "وقّع قائد الطاقم {{when}}", + "crewBossPending": "لم يوقّع قائد الطاقم بعد.", + "customerSigned": "موقّع الجهة: {{name}}", + "customerPending": "لم يُسجل توقيع الجهة.", + "asCrewBoss": "التوقيع كقائد للطاقم", + "customerName": "اسم موقّع الجهة", + "recordCustomer": "تسجيل توقيع الجهة" + }, + "expenses": { + "title": "المصروفات", + "type": { + "0": "بدل الوجبات اليومي", + "1": "السكن", + "2": "سكن خاص", + "3": "العبّارة", + "4": "الوقود", + "5": "إعادة تزويد المستلزمات", + "6": "أخرى" + }, + "typeLabel": "نوع المصروف", + "amount": "المبلغ ({{currency}})", + "description": "الوصف", + "city": "المدينة", + "takePhoto": "التقاط صورة", + "choosePhoto": "اختيار صورة", + "receiptAttached": "تم إرفاق صورة الإيصال.", + "save": "إضافة مصروف", + "add": "مصروف جديد", + "noneToday": "لا توجد مصروفات لهذا اليوم.", + "otherDays": "أيام أخرى", + "hasReceipt": "إيصال", + "remove": "إزالة", + "removeTitle": "إزالة المصروف؟", + "removeBody": "سيؤدي هذا إلى إزالة المصروف وإيصاله من عملية النشر.", + "cancel": "إلغاء", + "saved": "تم حفظ المصروف.", + "amountInvalid": "أدخل مبلغًا مثل 42.50.", + "photoDenied": "اسمح بالوصول إلى الكاميرا أو الصور لإرفاق إيصال." + } + }, "protocols": { "details": { "close": "إغلاق", @@ -846,6 +1294,172 @@ "unknown_type_warning": "نوع هذا الإشعار غير معروف. يرجى الاتصال بالدعم إذا استمر ظهور هذه الرسالة.", "view_call": "عرض المكالمة" }, + "records": { + "add_row": "إضافة صف", + "agencies": "الجهات", + "agreement": "الاتفاقية", + "assigned": "المُسنَد إليك", + "attachment_failed": "تعذّر إرسال المرفق.", + "attachment_file_missing": "لم يعد الملف موجودًا على هذا الجهاز.", + "attachment_unreadable": "تعذّرت قراءة الملف.", + "attachment_will_resume": "متوقّف مؤقتًا. سيستأنف من حيث توقف.", + "attachments": "المرفقات", + "attachments_hint": "تُضاف المرفقات بعد حفظ السجل.", + "attestation": "أُقر بأن هذا السجل كامل ودقيق", + "back": "رجوع", + "busy": "هناك قراءة قيد التنفيذ بالفعل", + "camera_denied": "الكاميرا غير متاحة دون إذن.", + "cancel": "إلغاء", + "captured_on": "تم الالتقاط", + "choose": "اختر…", + "choose_file": "اختيار ملف", + "choose_photo": "اختيار صورة", + "closed_out_on": "أُغلق في", + "closeout_notes": "ملاحظات الإغلاق", + "conflict_app_version": "السجلات غير مفعّلة لهذا الإصدار من التطبيق.", + "conflict_definition_retired": "سُحب هذا النموذج. ابدأ من جديد من القائمة الحالية.", + "conflict_etag": "غيّر شخص آخر هذا السجل. أعد تحميله وطبّق تغييراتك من جديد.", + "conflict_permission": "لم يعد لديك وصول إلى هذا السجل.", + "conflict_protected_data": "يتطلب هذا النموذج اتصالًا نشطًا ووصولك إلى البيانات المحمية.", + "conflict_scope": "تغيّر وصولك، لذا أُعيد تحميل السجل.", + "connector_disabled": "معطّل", + "connector_maintained": "يُدار بواسطة موصل", + "connector_not_ready": "غير جاهز", + "connector_ready": "جاهز", + "connectors": "الموصلات الخارجية", + "connectors_hint": "يقرأ كل موصل نظام طلبات واحدًا وفق شروطه، ببيانات اعتماد مشفرة وحد للطلبات. صلاحية الكتابة مرفوضة.", + "connectors_home_hint": "تغذيات أنظمة الطلبات وما يحتاج إلى مطابقة", + "cost_code": "رمز التكلفة", + "deployment_status_closed_out": "مغلق", + "deployment_status_mobilized": "تم التحريك", + "deployment_status_open": "مفتوح", + "deployment_status_released": "تم الإفراج", + "deployments": "عمليات النشر", + "deployments_hint": "يبقى نظام الطلبات هو المرجع. ينقل المنسق التعيينات عبر الويب؛ لا شيء هنا يغيّرها.", + "deployments_home_hint": "طلبات الموارد الخارجية التي يلبيها قسمك", + "drafts": "المسودات", + "field_required": "هذا الحقل مطلوب.", + "fill_status_accepted": "مقبول", + "fill_status_assigned": "تم التعيين", + "fill_status_checked_in": "تم تسجيل الوصول", + "fill_status_declined": "مرفوض", + "fill_status_demobilized": "تم إنهاء التحريك", + "fill_status_mobilized": "تم التحريك", + "fill_status_released": "تم الإفراج", + "fill_status_requested": "مطلوب", + "fill_status_returned": "عاد", + "fills": "الطلبات والتعيينات", + "finalize": "إنهاء", + "finalized": "تم الإنهاء.", + "fix_fields": "بعض الحقول ما زالت تحتاج إلى انتباه.", + "forbidden": "لمسؤولي القسم فقط", + "granted": "ممنوحة", + "hide_closed": "إخفاء عمليات النشر المغلقة", + "include_closed": "إظهار عمليات النشر المغلقة", + "last_error": "آخر خطأ", + "last_read": "آخر قراءة", + "last_success": "آخر نجاح", + "load_failed": "تعذّر تحميل السجل.", + "loading": "جارٍ تحميل السجلات…", + "location_kept": "تم الاحتفاظ بالموقع", + "location_removed": "تمت إزالة الموقع", + "manage_on_web": "يتم إنشاء الموصلات وتعديلها ومنحها بيانات الاعتماد عبر الويب.", + "minutes": "{{count}} دقيقة", + "needed_on": "مطلوب في", + "needs_attention": "يحتاج إليك", + "never_read": "لم تتم القراءة أبدًا", + "new_record": "سجل جديد", + "no_assignments": "لا شيء مُسنَد إليك.", + "no_attachments": "لا توجد مرفقات بعد.", + "no_connectors": "لا توجد موصلات", + "no_deployments": "لا توجد عمليات نشر", + "no_drafts": "ليس لديك مسودات.", + "no_fills": "لا توجد طلبات في هذا الطلب", + "no_recent": "لا توجد سجلات حديثة.", + "no_reconciliation": "لا توجد اختلافات بين المصدر وسجلاتك", + "no_returned": "لم يُعَد إليك شيء.", + "no_rows": "لا توجد صفوف بعد.", + "no_runs": "لا توجد عمليات تشغيل بعد", + "not_found": "هذا السجل غير متاح.", + "not_granted": "غير ممنوحة", + "not_sent_yet": "في انتظار الإرسال", + "nothing_to_create": "لا يمكن بدء أي شيء من هنا حاليًا.", + "offices": "المكاتب", + "offline_capable": "يعمل دون اتصال", + "online_only": "يُعبّأ هذا النموذج عبر الإنترنت فقط.", + "online_only_badge": "عبر الإنترنت فقط", + "open_artifact": "فتح مستند المصدر", + "open_record": "فتح السجل", + "pending": "لم يُرسَل", + "poll_interval": "فترة الاستطلاع", + "preview": "معاينة", + "purpose_acknowledge": "أقرّ بهذا السجل", + "purpose_attach": "أضف مرفقًا", + "purpose_complete": "أكمل هذا السجل", + "purpose_correct": "صحّح هذا السجل", + "purpose_review": "راجع هذا السجل", + "read_authority": "صلاحية القراءة", + "read_feed_now": "قراءة التغذية الآن", + "reading_feed": "جارٍ القراءة…", + "reason_app_disabled": "السجلات غير مفعّلة لهذا التطبيق في قسمك.", + "reason_app_version_too_old": "حدّث التطبيق لاستخدام السجلات.", + "reason_module_disabled": "السجلات غير متاحة لقسمك.", + "reason_not_member": "لست عضوًا نشطًا في هذا القسم.", + "reason_records_not_usable": "لم يفعّل قسمك السجلات بعد.", + "recent": "الأحدث", + "reconciliation": "المطابقة", + "reconciliation_count": "{{count}} للمطابقة", + "reconciliation_hint": "حيث يختلف المصدر عن سجلك. لا يُطبق شيء تلقائيًا؛ يقرر المنسق عبر الويب.", + "reconciliation_local_fill_missing_in_source": "التعيين موجود محليًا لكن ليس في المصدر", + "reconciliation_source_closed_local_open": "أغلق المصدر الطلب، لكن التعيينات لا تزال مفتوحة", + "reconciliation_source_released_local_not_returned": "أفرج المصدر عن الطلب، لكن أحد التعيينات لم يعد بعد", + "reconciliation_source_request_missing_locally": "الطلب موجود في المصدر لكن ليس محليًا", + "reconciliation_source_status_ahead": "حالة المصدر متقدمة على التعيين المحلي", + "reconciliation_source_status_behind": "حالة المصدر متأخرة عن التعيين المحلي", + "refresh_catalog": "تحديث", + "requests_this_hour": "الطلبات في هذه الساعة", + "restricted": "مقيّد", + "returned": "أُعيد إليك", + "run_counts": "المرئية {{seen}} · المنشأة {{created}} · اللقطات {{snapshots}} · الطلبات {{requests}} · بدون تغيير {{unchanged}} · المرفوضة {{rejected}} · للمطابقة {{conflicts}}", + "run_failed": "تعذّرت قراءة التغذية", + "run_finished": "تمت قراءة التغذية {{outcome}}: {{created}} منشأة، {{snapshots}} لقطات، {{requests}} طلبات، {{conflicts}} للمطابقة", + "run_log": "سجل التشغيل", + "run_not_ok": "انتهت قراءة التغذية بنتيجة {{outcome}}", + "run_outcome_disabled": "معطّل", + "run_outcome_failed": "فشل", + "run_outcome_ok": "ناجح", + "run_outcome_rate_limited": "تجاوز حد الطلبات", + "run_outcome_rejected": "مرفوض", + "run_trigger_inbound": "مُرسل من المصدر", + "run_trigger_manual": "يدوي", + "run_trigger_poll": "مجدول", + "save": "حفظ", + "save_and_send": "حفظ وإرسال", + "save_failed": "تعذّر الحفظ.", + "save_locally": "الاحتفاظ على هذا الجهاز", + "saved": "تم الحفظ.", + "saved_locally": "تم الاحتفاظ به على الجهاز. سيُرسَل عند عودتك للاتصال.", + "sign_statement": "أوقّع هذا السجل بصفتي المؤلف", + "source": "المصدر", + "source_says": "المصدر:", + "submit_for_review": "إرسال للمراجعة", + "submitted": "أُرسل للمراجعة.", + "take_photo": "التقاط صورة", + "terms": "شروط المصدر", + "terms_acknowledged": "تم الإقرار", + "terms_not_acknowledged": "لم يتم الإقرار بعد", + "title": "السجلات", + "trainee": "متدرب", + "unavailable": "السجلات غير متاحة في هذا التطبيق حاليًا.", + "unit_code": "الوحدة", + "unsupported_definition": "يستخدم هذا النموذج حقلًا لا يمكن لهذا الإصدار تعبئته. حدّث التطبيق أو استخدم تطبيق الويب.", + "unsupported_field": "لا يستطيع هذا التطبيق عرض هذا الحقل بعد. استخدم تطبيق الويب لتعبئته.", + "untitled": "بدون عنوان", + "update_required": "مطلوب تحديث التطبيق", + "write_authority": "صلاحية الكتابة", + "write_refused": "مرفوضة — لا يُكتب أي شيء إلى المصدر", + "your_record_says": "سجلك:" + }, "roles": { "assignedElsewhere": "في دور آخر", "clearAssignment": "مسح الإسناد الحالي", @@ -1117,6 +1731,17 @@ "version": "الإصدار", "website": "الموقع الإلكتروني" }, + "site_info": { + "additional_contact": "جهة اتصال إضافية", + "alert_notes": "ملاحظات التنبيه", + "empty": "لا توجد معلومات عن الموقع", + "empty_description": "لا توجد جهات اتصال مرتبطة بهذا البلاغ لديها خطة مسبقة أو مخاطر أو ملاحظات تنبيه أو ملفات.", + "entrance": "المدخل", + "files": "ملفات الموقع", + "load_failed": "تعذر تحميل معلومات الموقع.", + "loading": "جارٍ تحميل معلومات الموقع...", + "primary_contact": "جهة الاتصال الرئيسية" + }, "status": { "add_note": "إضافة ملاحظة", "both_destinations_enabled": "يمكن الاستجابة للمكالمات أو المحطات", @@ -1157,6 +1782,7 @@ "map": "الخريطة", "notes": "الملاحظات", "protocols": "البروتوكولات", + "records": "السجلات", "routes": "المسارات", "settings": "الإعدادات", "weather_alerts": "الطقس" @@ -1277,5 +1903,80 @@ "unknown": "غير معروف" } }, - "welcome": "مرحبًا بك في موقع تطبيق Resgrid Unit" + "welcome": "مرحبًا بك في موقع تطبيق Resgrid Unit", + "inventory": { + "title": "المخزون", + "titleFor": "المخزون · {{name}}", + "selectUnit": "اختر وحدتك أولًا.", + "unnamedLocation": "حجرة", + "unnamedItem": "عنصر", + "countName": "فحص {{name}} {{date}}", + "notMigrated": "يجري إعداد المخزون لإدارتك.", + "disabled": "المخزون متوقف لإدارتك.", + "noLocations": "لا يوجد مخزون مخصص لهذه الوحدة.", + "startCount": "جرد المخزون", + "wholeUnit": "الوحدة بالكامل: {{name}}", + "pickLocation": "أين يتم الجرد", + "startHelp": "اجرد ما هو موجود فعلًا في الوحدة. تُسجل الفروقات عند إكمال الجرد.", + "start": "بدء الجرد", + "cannotCount": "يتطلب الجرد صلاحية تعديل المخزون في هذه الوحدة.", + "counts": "عمليات الجرد الأخيرة", + "count": "جرد المخزون", + "empty": "لا شيء هنا.", + "serial": "الرقم التسلسلي {{serial}}", + "quantity": "× {{quantity}}", + "back": "رجوع", + "completeTitle": "إكمال الجرد؟", + "completeBody": "تختلف {{count}} عناصر عن المتوقع. سيؤدي الإكمال إلى تسجيل الفروقات.", + "completeNoChanges": "كل شيء مطابق للمتوقع.", + "cancel": "إلغاء", + "complete": "إكمال", + "cancelTitle": "إلغاء هذا الجرد؟", + "cancelBody": "لن يُسجل أي شيء تم جرده حتى الآن.", + "keep": "متابعة الجرد", + "cancelCount": "إلغاء الجرد", + "progress": "تم جرد {{counted}} من {{total}}", + "differences": "{{count}} فروقات", + "awaitingWitness": "تغير عنصر خاضع للرقابة؛ يجب أن يشهد شخص ثانٍ على هذا الجرد عبر الويب.", + "completed": "اكتمل الجرد.", + "expected": "المتوقع {{quantity}} {{unit}}", + "present": "موجود", + "missing": "مفقود", + "less": "واحد أقل", + "more": "واحد أكثر", + "matches": "مطابق", + "save": "حفظ التقدم", + "countStatus": { + "0": "قيد التنفيذ", + "1": "بانتظار شاهد", + "2": "مكتمل", + "3": "ملغى" + }, + "assetStatus": { + "0": "في الخدمة", + "1": "مصروف", + "2": "قيد الإصلاح", + "3": "تالف", + "4": "مفقود", + "5": "مستهلك", + "6": "متقاعد" + }, + "errors": { + "retry": "تعذر الوصول إلى الخادم. حاول مرة أخرى.", + "locked": "افتح البيانات المحمية للمتابعة.", + "PermissionRequired": "ليست لديك صلاحية للقيام بذلك.", + "MembershipRequired": "لست عضوًا نشطًا في هذه الإدارة.", + "InventoryDisabled": "المخزون متوقف لإدارتك.", + "MigrationRequired": "يجري إعداد المخزون لإدارتك.", + "CountScopeTooLarge": "عناصر كثيرة جدًا لجرد واحد. اجرد حجرة واحدة في كل مرة.", + "CountEmpty": "لا يوجد ما يُجرد هنا.", + "CountSnapshotChanged": "تغير المخزون في هذه الوحدة أثناء الجرد. ألغِ هذا الجرد وابدأ من جديد.", + "CountStateConflict": "تغير هذا الجرد على جهاز آخر. افتحه مجددًا وحاول مرة أخرى.", + "ReturnAssetFirst": "عنصر معلّم كمفقود لا يزال مصروفًا لشخص ما. أرجعه أولًا عبر الويب.", + "ProtectedDataRequired": "افتح البيانات المحمية للمتابعة.", + "CountAlreadyExists": "هذا الجرد موجود بالفعل.", + "LocationUnavailable": "هذا الموقع غير متاح.", + "InvalidCount": "تحقق من القيم المجرودة وحاول مرة أخرى." + } + } } diff --git a/src/translations/de.json b/src/translations/de.json index eda08ed2..7446c369 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -126,6 +126,12 @@ "title": "Anrufnotizen" }, "call_detail": { + "activity_link": { + "auto": "Automatisch verknüpft", + "auto_hint": "Ohne Anruf gesendet; Resgrid hat es über den vorherigen Status oder die Entsendung verknüpft.", + "inferred": "Abgeleitet", + "inferred_hint": "Ohne Ziel von einer Einheit oder Person gesetzt, die zu diesem Anruf entsandt wurde und daran arbeitete." + }, "address": "Adresse", "call_location": "Anrufstandort", "close_call": "Anruf schließen", @@ -201,6 +207,7 @@ "dispatched": "Entsendet", "info": "Info", "protocols": "Protokolle", + "site": "Objektinfo", "timeline": "Aktivität" }, "timestamp": "Zeitstempel", @@ -228,6 +235,9 @@ "contact_name_placeholder": "Namen des Kontakts eingeben", "contact_phone": "Kontakttelefon", "contact_phone_placeholder": "Telefonnummer des Kontakts eingeben", + "contact_picker": { + "linked": "Verknüpfter Kontakt: {{name}}" + }, "coordinates": "GPS-Koordinaten", "coordinates_found": "Koordinaten gefunden und Adresse aktualisiert", "coordinates_geocoding_error": "Adresse für Koordinaten konnte nicht abgerufen werden, aber Standort wurde auf der Karte gesetzt", @@ -445,6 +455,206 @@ "type_sector_rotation": "Sektorwechsel", "type_unit": "Einheit" }, + "checklists": { + "errors": { + "conflict": "Diese Prüfung wurde auf dem Server geändert. Ihre lokalen Daten bleiben zur Überprüfung erhalten.", + "denied": "Der Zugriff ist nicht verfügbar. Ihre verschlüsselten lokalen Daten bleiben erhalten.", + "locked": "Entsperren Sie die geschützten Daten, um fortzufahren.", + "offline": "Offline: Sicher gespeicherte Prüfungen werden angezeigt.", + "retry": "Die Synchronisierung konnte nicht abgeschlossen werden. Ihre lokalen Daten bleiben erhalten.", + "storage_full": "Die lokale Grenze für Entwürfe oder Nachweise wurde erreicht.", + "validation": "Prüfen Sie Pflichtantworten, Datumsangaben, Notizen und Nachweise und versuchen Sie es erneut." + }, + "labels": { + "Actions": "Aktionen", + "Active": "Aktiv", + "AnchorMonth": "Ausgangsmonat", + "Annual": "Jährlich", + "Answer": "Antwort", + "Answered": "Beantwortet", + "AssetUnavailable": "Einzeln erfasste Ausrüstung ist nicht verfügbar.", + "Assignment": "Zugewiesen an", + "AssignmentAutomatic": "Automatische Zuordnung", + "AssignmentPermission": "Sie sind dieser Prüfung nicht zugewiesen.", + "AssignmentType1": "Einzelperson", + "AssignmentType2": "Personalrolle", + "AssignmentType3": "Gruppe oder Wache", + "AssignmentType4": "Fahrzeugbesatzung", + "AssignmentUnavailable": "Wählen Sie eine verfügbare Zuweisung in dieser Organisation.", + "Attest": "Bestätigen und abschließen", + "Attestation": "Erklärung zur unabhängigen Prüfung", + "Author": "Verfasser", + "AwaitingWitness": "Warten auf Zeugenbestätigung", + "Back": "Zurück", + "CalendarInclude": "Bereitschaftsprüfungen", + "CalendarRange": "Wählen Sie einen Kalenderzeitraum von höchstens 93 Tagen.", + "Cancelled": "Abgebrochen", + "Category": "Kategorie", + "Checkbox": "Kontrollkästchen", + "Checked": "Aktiviert", + "ChecklistCompleted": "Prüfliste abgeschlossen", + "ChecklistFailed": "Prüfliste nicht bestanden", + "ChecklistMissed": "Prüfliste versäumt", + "ChecklistOccurrenceSkipped": "Geplante Prüfung entschuldigt", + "ChecklistScheduleChanged": "Prüfzeitplan geändert", + "Checklists": "Checklisten", + "Checks": "Prüfungen", + "Choose": "Auswählen", + "Critical": "Kritisch", + "Daily": "Täglich", + "Date": "Datum", + "DayOfMonth": "Tag des Monats", + "DeleteDraft": "Entwurf löschen", + "Department": "Abteilung", + "Draft": "Entwurf", + "DraftGuidance": "Speichern Sie einen Entwurf und veröffentlichen Sie ihn. Bestehende Durchläufe behalten ihre ursprüngliche Version. Keine Patientendaten eingeben.", + "DueChecks": "Fällige Prüfungen", + "DueGuidance": "Anstehende Prüfungen für sieben Tage und offene Prüfungen. Versäumte Prüfungen können weiterhin abgeschlossen werden.", + "EditChecklist": "Checklisten-Editor", + "EditDraft": "Entwurf bearbeiten", + "EditSchedule": "Zeitplan bearbeiten", + "Evidence": "Nachweis", + "ExcuseCheck": "Prüfung entschuldigen", + "Export": "JSON exportieren", + "Facility": "Einrichtung", + "Fail": "Nicht bestanden", + "FailNote": "Eine nicht bestandene Prüfung erfordert eine Anmerkung.", + "Failed": "Nicht bestanden", + "Free": "Checklisten sind für alle Abteilungen kostenlos.", + "Frequency": "Häufigkeit", + "Guidance": "Passen Sie die Vorlagen vor dem Einsatz an Ihre Ausrüstung, Herstelleranweisungen und örtlichen Anforderungen an.", + "History": "Verlauf", + "InProgress": "In Bearbeitung", + "Incomplete": "Unvollständig", + "InventoryAsset": "Einzeln erfasste Ausrüstung", + "Item": "Punkt", + "Maintenance": "Wartung und Arbeitsaufträge (Readiness Pro)", + "Missed": "Versäumt", + "MonthGuidance": "Für monatliche oder seltenere Prüfungen. In kurzen Monaten gilt der letzte Tag. Der Ausgangsmonat bestimmt vierteljährliche, halbjährliche und jährliche Prüfungen.", + "Monthly": "Monatlich", + "Name": "Name", + "NewChecklist": "Neue Checkliste", + "NewSchedule": "Neuer Zeitplan", + "Next": "Weiter", + "No": "Nein", + "NoDefinitions": "Keine Checklisten auf dieser Seite.", + "NoDueChecks": "Keine Prüfungen in dieser Liste.", + "NoResults": "Keine passenden Vorlagen gefunden.", + "NoSchedules": "Noch keine Zeitpläne.", + "NoScore": "Keine bewertbaren Punkte", + "Note": "Anmerkung", + "OccurrenceUnavailable": "Diese Prüfung ist nicht verfügbar oder wurde bereits geändert.", + "OpenCheck": "Prüfung öffnen", + "Optional": "Optional", + "Other": "Sonstiges", + "Pass": "Bestanden", + "Passed": "Bestanden", + "PerShift": "Je Schicht", + "Personnel": "Personal", + "Photo": "Foto", + "Preview": "Vorlage ansehen", + "Previous": "Zurück", + "Print": "Drucken", + "PublishDraft": "Entwurf veröffentlichen", + "PublishGuidance": "Die Veröffentlichung erstellt eine unveränderliche Version für neue Durchläufe.", + "Published": "Veröffentlicht", + "PublishedVersion": "Veröffentlichte Version", + "Quantity": "Menge", + "Quarterly": "Vierteljährlich", + "ReminderBefore": "Minuten vor der Fälligkeit (0 deaktiviert)", + "ReminderDigest": "Ausstehende Erinnerungen zusammenfassen", + "ReminderEnabled": "Erinnerungen an Checklisten aktivieren", + "ReminderEscalate": "Nach so vielen Minuten Überfälligkeit die Abteilungsadministratoren benachrichtigen (leer deaktiviert)", + "ReminderFixedTime": "Uhrzeit der täglichen Zusammenfassung (Zeitzone der Organisation; ersetzt Schichtbeginn)", + "ReminderHelp": "Erinnerungen gelten für Prüfungen, die nach dem Aktivieren beginnen. Nachrichten enthalten einen Anmeldelink und berücksichtigen die Benachrichtigungseinstellungen jeder Person. Ausstehende Erinnerungen werden je Empfänger zusammengefasst.", + "ReminderMessage": "Aktualisierungen zu Checklisten sind verfügbar. Melden Sie sich an, um Ihre Prüfungen einzusehen.", + "ReminderMissed": "Zuständiges Personal über versäumte Prüfungen benachrichtigen", + "ReminderSettings": "Erinnerungen an Checklisten", + "ReminderShiftStart": "Zusammenfassung bei Schichtbeginn senden", + "ReminderTimeValidation": "Geben Sie eine Uhrzeit im Format HH:mm ein.", + "ReminderTitle": "Erinnerungen an Checklisten", + "ReminderValidation": "Geben Sie 0–10080 Minuten für Erinnerungen und 1–43200 Minuten für die Eskalation ein oder lassen Sie die Eskalation leer.", + "Remove": "Entfernen", + "ReportedLocation": "Gemeldeter Standort", + "Required": "Erforderlich", + "Result": "Ergebnis", + "Retire": "Stilllegen", + "Retired": "Stillgelegt", + "RunGuidance": "Beantworten Sie alle zutreffenden Punkte. Nicht zutreffend erfordert eine Begründung. Speichern Sie den Fortschritt, um später fortzufahren. Eingereichte Antworten und Nachweise sind unveränderlich.", + "SaveDraft": "Entwurf speichern", + "SaveProgress": "Fortschritt speichern", + "SaveReminders": "Erinnerungseinstellungen speichern", + "SaveSchedule": "Zeitplan speichern", + "ScheduleEnabled": "Zeitplan aktiviert", + "ScheduleEndDate": "Enddatum (optional)", + "ScheduleNotes": "Hinweise zum Zeitplan", + "SchedulePaused": "Pausiert", + "SchedulePermission": "Sie dürfen keine Prüfzeitpläne verwalten.", + "ScheduleRequiresPublished": "Veröffentlichen Sie die Prüfliste, bevor Sie sie einplanen.", + "ScheduleStartDate": "Startdatum", + "ScheduleTimeGuidance": "Die Zeiten gelten in dieser Zeitzone. Nicht vorhandene Uhrzeiten werden auf die nächste gültige Minute verschoben; doppelte Uhrzeiten werden nur einmal berücksichtigt.", + "ScheduleUnavailable": "Dieser Zeitplan ist nicht verfügbar.", + "ScheduleValidation": "Prüfen Sie Daten, Uhrzeiten, Ziel und Wiederholung des Zeitplans.", + "ScheduleVersionGuidance": "Beim Speichern wird die aktuell veröffentlichte Version festgelegt. Änderungen ersetzen künftige unbegonnene Prüfungen; Pausieren hebt unbegonnene Prüfungen auf.", + "ScheduleWorkshift": "Dienstschicht für Prüfungen je Schicht", + "Scheduled": "Geplant", + "ScheduledFor": "Geplant für", + "Schedules": "Zeitpläne", + "Score": "Punktzahl", + "Search": "Suchen", + "SearchLabel": "Vorlage finden", + "SelectTarget": "Ziel auswählen", + "SemiAnnual": "Halbjährlich", + "Signature": "Unterschrift", + "SkipReason": "Grund für die entschuldigte Prüfung", + "SkipReasonRequired": "Geben Sie einen Grund mit höchstens 4.000 Zeichen ein.", + "Skipped": "Entschuldigt", + "StartRun": "Checkliste starten", + "Started": "Gestartet", + "State": "Status", + "SubmitRun": "Checkliste einreichen", + "Submitted": "Eingereicht", + "Target": "Ziel", + "Templates": "Checklisten-Vorlagen", + "TimeZone": "Zeitzone", + "TimesOfDay": "Ortszeiten", + "TimesOfDayGuidance": "Geben Sie bis zu acht Uhrzeiten im 24-Stunden-Format ein, getrennt durch Kommas.", + "Unanswered": "Unbeantwortet", + "Unchecked": "Nicht aktiviert", + "Unit": "Einheit", + "Units": "Einheiten", + "Version": "Version", + "Weekly": "Wöchentlich", + "WeeklyDays": "Tage für wöchentliche Prüfungen", + "WindowEnds": "Zeitraum endet", + "WindowMinutes": "Bearbeitungszeitraum (Minuten)", + "Witness": "Ein angemeldeter Verfasser und ein anderes angemeldetes Mitglied müssen das Ergebnis unabhängig bestätigen.", + "WitnessInstructions": "Ein anderes angemeldetes Mitglied muss die eingereichten Antworten und Nachweise unabhängig prüfen. Teilen Sie den Link zu dieser Seite mit diesem Mitglied.", + "WitnessLabel": "Zeuge", + "WorkshiftFallback": "Täglich Ortszeiten verwenden", + "WorkshiftGuidance": "Nur für Prüfungen je Schicht. Eine gewählte Dienstschicht nutzt ihre erfassten Startdaten; sonst gelten täglich die Ortszeiten.", + "Yes": "Ja" + }, + "ui": { + "calendar": "Kalender", + "clearSignature": "Unterschrift löschen", + "close": "Schließen", + "confirm": "Ersetzen", + "editLocal": "Lokalen Entwurf bearbeiten", + "localDrafts": "Lokale Entwürfe", + "notApplicable": "Nicht zutreffend", + "prepareOffline": "Für Offline-Nutzung vorbereiten", + "queued": "Sicher auf diesem Gerät gespeichert; Synchronisierung ausstehend.", + "reason": "Begründung", + "refresh": "Aktualisieren", + "removeImage": "Bild entfernen", + "replaceLocal": "Lokale Antworten und ausstehende Bilder durch die Serverversion ersetzen?", + "retry": "Synchronisierung wiederholen", + "takePhoto": "Foto aufnehmen", + "unlock": "Geschützte Daten entsperren", + "useServer": "Serverversion verwenden" + } + }, "common": { "add": "Hinzufügen", "back": "Zurück", @@ -536,6 +746,12 @@ "expires": "Läuft ab", "facebook": "Facebook", "faxPhone": "Fax", + "files": { + "download_failed": "Die Datei konnte nicht heruntergeladen werden.", + "empty": "An diesen Kontakt wurden keine Dateien angehängt.", + "loading": "Dateien werden geladen...", + "share_unavailable": "Teilen ist auf diesem Gerät nicht verfügbar." + }, "formError": "Bitte Fehler im Formular beheben", "homePhone": "Heimtelefon", "identification": "Identifikation", @@ -557,6 +773,48 @@ "otherInfo": "Sonstige Informationen", "person": "Person", "phone": "Telefon", + "preplan": { + "access": "Zugang", + "access_notes": "Zugangshinweise", + "alarm_company": "Alarmfirma", + "alarm_company_phone": "Telefon Alarmfirma", + "alarm_panel": "Brandmeldezentrale", + "alert_flag": "Warnung", + "construction": "Bauweise · Dach · Nutzung", + "electric_shutoff": "Stromabschaltung", + "emergency_contact": "Notfallkontakt", + "emergency_contact_phone": "Telefon Notfallkontakt", + "gas_shutoff": "Gasabsperrung", + "gate_code": "Torcode", + "general_hazard_notes": "Allgemeine Gefahrenhinweise", + "general_hazards": "Gefahren & Taktik", + "hazards": "Gefahren am Objekt", + "hazmat_on_site": "Gefahrstoffe vor Ort", + "knox_box": "Schlüsseldepot", + "last_reviewed": "Zuletzt überprüft", + "loading": "Einsatzplan wird geladen...", + "location": "Ort", + "nearest_hydrant": "Nächster Hydrant", + "never_reviewed": "Nie überprüft", + "next_review": "Nächste Überprüfung", + "none": "Für diesen Kontakt wurde kein Einsatzplan erfasst.", + "occupancy": "Bauweise & Nutzung", + "occupancy_hours": "Nutzungszeiten", + "occupancy_notes": "Hinweise zur Nutzung", + "occupant_load": "Personenzahl", + "occupants_needing_assistance": "Personen benötigen möglicherweise Evakuierungshilfe", + "on_site_contacts": "Ansprechpartner vor Ort", + "required_fire_flow": "Erforderliche Löschwassermenge", + "review_overdue": "Das Überprüfungsdatum des Einsatzplans ist abgelaufen.", + "secondary_contact": "Zweiter Kontakt", + "secondary_contact_phone": "Telefon zweiter Kontakt", + "tactical_summary": "Taktische Zusammenfassung", + "utilities": "Versorgungsabsperrungen", + "utility_notes": "Hinweise zur Versorgung", + "water_shutoff": "Wasserabsperrung", + "water_supply": "Löschwasserversorgung", + "water_supply_notes": "Hinweise zur Löschwasserversorgung" + }, "public": "Öffentlich", "required": "Erforderlich", "save": "Kontakt speichern", @@ -569,7 +827,9 @@ "systemInformation": "Systeminformationen", "tabs": { "details": "Details", - "notes": "Notizen" + "files": "Dateien", + "notes": "Notizen", + "preplan": "Einsatzplan" }, "threads": "Threads", "title": "Kontakte", @@ -578,7 +838,13 @@ "unknown_person": "Unbekannte Person", "visibility": "Sichtbarkeit", "website": "Website", - "zip": "Postleitzahl" + "zip": "Postleitzahl", + "withheld": "{{count}} geschützte Angaben sind ausgeblendet.", + "categoryLabel": "Kategorie: {{name}}", + "physicalAddress": "Anschrift", + "mailingAddress": "Postanschrift", + "hasGeofence": "Für diesen Ort ist ein Geofence hinterlegt.", + "customFields": "Benutzerdefinierte Felder" }, "data_protection": { "conceal": "Wieder ausblenden", @@ -813,11 +1079,193 @@ "select_all": "Alle auswählen", "selected_count": "{{count}} ausgewählt", "title": "Benachrichtigungen", - "unable_to_load": "Benachrichtigungen konnten nicht geladen werden" + "unable_to_load": "Benachrichtigungen konnten nicht geladen werden", + "detail_title": "Benachrichtigung", + "additional_info": "Zusätzliche Informationen", + "view_call": "Anruf anzeigen", + "view_chat": "Chat anzeigen", + "open_reference": "Öffnen" }, "onboarding": { "message": "Willkommen bei Resgrid Unit app site" }, + "operations": { + "all": "Alle", + "back": "Zurück", + "disabled": "Einsätze sind für diese Abteilung nicht aktiviert.", + "entryType": { + "0": "Einsatz", + "1": "Bereitschaft", + "2": "Anfahrt" + }, + "errors": { + "denied": "Sie haben keinen Zugriff auf diesen Einsatz.", + "disabled": "Diese Funktion ist für Ihre Abteilung deaktiviert.", + "locked": "Geschützte Daten entsperren, um fortzufahren.", + "retry": "Server nicht erreichbar. Bitte erneut versuchen.", + "validation": "Der Server hat die Änderung abgelehnt.", + "timereports_subject_covered": "Für diese Besatzung oder Person ist an diesem Tag bereits Zeit in einem anderen Bericht erfasst.", + "timereports_date_exists": "Für diese Besatzung oder Person gibt es an diesem Tag bereits einen Bericht.", + "timereports_scope_invalid": "Diese Besatzung oder Person ist in diesem Einsatz nicht aktiv.", + "timereports_locked": "Dieser Bericht kann nicht mehr geändert werden.", + "timereports_time_invalid": "Geben Sie Zeiten als HH:mm ein.", + "deployments_closed": "Dieser Einsatz ist abgeschlossen.", + "expenses_receipt_invalid": "Der Beleg konnte nicht gelesen werden.", + "deployments_attachment_too_large": "Der Beleg ist zu groß." + }, + "financeMode": { + "0": "Operativ", + "1": "Kostenerstattung", + "2": "Abrechenbar" + }, + "issues": { + "end_before_start": "Die Endzeit muss nach der Startzeit liegen.", + "missing_subject": "Wählen Sie, für wen oder was dieser Eintrag gilt.", + "missing_time": "Start- und Endzeit eingeben.", + "overlap": "Dieser Eintrag überschneidet sich mit einem anderen für dieselbe Person oder Einheit.", + "subject_outside_report_scope": "Dieser Eintrag betrifft jemanden außerhalb der Besatzung oder Person dieses Berichts.", + "subject_on_other_report": "Für diese Person oder Einheit ist am selben Tag bereits Zeit in einem anderen Bericht erfasst.", + "subject_not_on_roster": "Dieser Eintrag betrifft jemanden, der nicht in der Besetzung steht.", + "entries_overlap": "Zwei Einträge derselben Person oder Einheit überschneiden sich.", + "no_entries": "Fügen Sie mindestens einen Eintrag hinzu.", + "break_rule": "Für eine lange Schicht ist keine Pause erfasst.", + "long_travel": "Mehr Reisezeit als an einem Tag erlaubt; sie muss genehmigt werden.", + "outside_report_date": "Dieser Eintrag liegt außerhalb des Berichtsdatums." + }, + "mars": { + "counts": "{{errors}} Fehler · {{warnings}} Warnungen", + "draft": "F-42 aus Zeitberichten entwerfen", + "f42": "F-42", + "help": "Das F-42 wird aus eingereichten Zeitberichten erstellt und gegen die Regeln des CAL OES-Portals geprüft. Die Einreichung bleibt ein manueller, protokollierter Schritt im Web.", + "none": "Noch keine MARS-Vorgänge für diesen Einsatz.", + "notReady": "Nicht bereit für das Portal", + "ready": "Bereit für das Portal", + "record": "MARS {{id}} · {{status}}", + "title": "F-42", + "validate": "Prüfen" + }, + "noNumbers": "Keine Vorfall- oder Anforderungsnummern", + "none": "Keine Einsätze.", + "open": "Offen", + "phase": { + "0": "Mobilisierung", + "1": "Bereitschaft", + "2": "Einsatz", + "3": "Rückkehr" + }, + "reportStatus": { + "0": "Entwurf", + "1": "Eingereicht", + "2": "Genehmigt", + "3": "Abgerechnet", + "4": "Storniert" + }, + "roster": "{{units}} Einheiten · {{personnel}} Personen", + "status": { + "0": "Geplant", + "1": "Bereitschaft", + "2": "Aktiv", + "3": "Demobilisierung", + "4": "Abgeschlossen", + "5": "Abgebrochen" + }, + "time": { + "accommodation": "Unterkunft von der Behörde gestellt", + "addEntry": "Eintrag hinzufügen", + "from": "Von", + "hours": "{{hours}} h", + "meals": "Verpflegung von der Behörde gestellt", + "noEntries": "Noch keine Einträge.", + "noReport": "Kein Zeitbericht für diesen Tag.", + "notes": "Notizen", + "remove": "Entfernen", + "report": "Bericht #{{number}}", + "save": "Einträge speichern", + "start": "Zeitbericht beginnen", + "subject": "Wer oder was", + "submit": "Bericht einreichen", + "title": "Zeit", + "to": "Bis", + "total": "Gesamt {{hours}} h", + "type": "Eintragsart", + "unpaidBreak": "Unbezahlte Pause (Min.)", + "crewReport": "Besatzungszeitbericht #{{number}} · {{name}}", + "individualReport": "Zeitbericht #{{number}} · {{name}}", + "noCrewReport": "Kein Besatzungszeitbericht für {{name}} an diesem Tag.", + "coveredBy": "Diese Zeit steht an diesem Tag bereits in Bericht #{{number}}.", + "startCrew": "Besatzungszeitbericht beginnen", + "readOnly": "Sie können diesen Bericht ansehen, aber nicht ändern.", + "nextDay": "Endet am nächsten Tag", + "applyToCrew": "Auf Besatzung übertragen", + "approve": "Bericht genehmigen", + "dayReports": "Berichte an diesem Tag", + "awaitingApproval": "Wartet auf Genehmigung ({{count}})", + "notOnRoster": "Sie stehen nicht in der Besetzung oder Besatzung dieses Einsatzes." + }, + "title": "Einsätze", + "usage": { + "add": "Messwert hinzufügen", + "endOdometer": "Kilometerstand Ende", + "engineHours": "Betriebsstunden", + "fuel": "Kraftstoff", + "needsReview": "Prüfung erforderlich", + "phase": "Phase", + "saved": "Messwert gespeichert.", + "startOdometer": "Kilometerstand Start", + "title": "Nutzung", + "unit": "Einheit" + }, + "crewOf": "Besatzung von {{names}}", + "previousDay": "Vorheriger Tag", + "nextDay": "Nächster Tag", + "scope": { + "deployment": "Gesamter Einsatz", + "mine": "Meine Zeit", + "crew": "Besatzung: {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "Unterschriften", + "crewBossSigned": "Vom Besatzungsführer unterschrieben {{when}}", + "crewBossPending": "Noch nicht vom Besatzungsführer unterschrieben.", + "customerSigned": "Unterzeichner der Behörde: {{name}}", + "customerPending": "Keine Unterschrift der Behörde erfasst.", + "asCrewBoss": "Als Besatzungsführer unterschreiben", + "customerName": "Name des Unterzeichners der Behörde", + "recordCustomer": "Unterschrift der Behörde erfassen" + }, + "expenses": { + "title": "Ausgaben", + "type": { + "0": "Verpflegungspauschale", + "1": "Unterkunft", + "2": "Private Unterkunft", + "3": "Fähre", + "4": "Kraftstoff", + "5": "Materialnachschub", + "6": "Sonstiges" + }, + "typeLabel": "Ausgabenart", + "amount": "Betrag ({{currency}})", + "description": "Beschreibung", + "city": "Ort", + "takePhoto": "Foto aufnehmen", + "choosePhoto": "Foto auswählen", + "receiptAttached": "Belegfoto angehängt.", + "save": "Ausgabe hinzufügen", + "add": "Neue Ausgabe", + "noneToday": "Keine Ausgaben an diesem Tag.", + "otherDays": "Andere Tage", + "hasReceipt": "Beleg", + "remove": "Entfernen", + "removeTitle": "Ausgabe entfernen?", + "removeBody": "Die Ausgabe und ihr Beleg werden aus dem Einsatz entfernt.", + "cancel": "Abbrechen", + "saved": "Ausgabe gespeichert.", + "amountInvalid": "Geben Sie einen Betrag wie 42.50 ein.", + "photoDenied": "Erlauben Sie den Zugriff auf Kamera oder Fotos, um einen Beleg anzuhängen." + } + }, "protocols": { "details": { "close": "Schließen", @@ -846,6 +1294,172 @@ "unknown_type_warning": "Dieser Benachrichtigungstyp wird nicht erkannt. Bitte Support kontaktieren, wenn diese Meldung weiterhin angezeigt wird.", "view_call": "Anruf anzeigen" }, + "records": { + "add_row": "Zeile hinzufügen", + "agencies": "Behörden", + "agreement": "Vereinbarung", + "assigned": "Ihnen zugewiesen", + "attachment_failed": "Dieser Anhang konnte nicht gesendet werden.", + "attachment_file_missing": "Die Datei ist nicht mehr auf diesem Gerät.", + "attachment_unreadable": "Diese Datei konnte nicht gelesen werden.", + "attachment_will_resume": "Angehalten. Es wird dort fortgesetzt, wo es aufhörte.", + "attachments": "Anhänge", + "attachments_hint": "Anhänge werden nach dem Speichern des Datensatzes hinzugefügt.", + "attestation": "Ich bestätige, dass dieser Datensatz vollständig und korrekt ist", + "back": "Zurück", + "busy": "Ein Lesevorgang läuft bereits", + "camera_denied": "Ohne Berechtigung ist die Kamera nicht verfügbar.", + "cancel": "Abbrechen", + "captured_on": "Erfasst", + "choose": "Auswählen…", + "choose_file": "Datei auswählen", + "choose_photo": "Foto auswählen", + "closed_out_on": "Abgeschlossen am", + "closeout_notes": "Abschlussnotizen", + "conflict_app_version": "Datensätze sind für diese App-Version nicht aktiviert.", + "conflict_definition_retired": "Dieses Formular wurde ausgemustert. Starten Sie es erneut aus der aktuellen Liste.", + "conflict_etag": "Jemand anderes hat diesen Datensatz geändert. Laden Sie ihn neu und übernehmen Sie Ihre Änderungen erneut.", + "conflict_permission": "Sie haben keinen Zugriff mehr auf diesen Datensatz.", + "conflict_protected_data": "Dieses Formular benötigt eine aktive Verbindung und Ihren Zugriff auf geschützte Daten.", + "conflict_scope": "Ihr Zugriff hat sich geändert, daher wurde dieser Datensatz neu geladen.", + "connector_disabled": "Deaktiviert", + "connector_maintained": "Von Konnektor gepflegt", + "connector_not_ready": "Nicht bereit", + "connector_ready": "Bereit", + "connectors": "Externe Konnektoren", + "connectors_hint": "Jeder Konnektor liest ein Bestellsystem unter dessen Bedingungen, mit verschlüsselten Zugangsdaten und einem Anfragelimit. Schreibberechtigung wird verweigert.", + "connectors_home_hint": "Feeds der Bestellsysteme und was abzugleichen ist", + "cost_code": "Kostenstelle", + "deployment_status_closed_out": "Abgeschlossen", + "deployment_status_mobilized": "Mobilisiert", + "deployment_status_open": "Offen", + "deployment_status_released": "Freigegeben", + "deployments": "Einsätze", + "deployments_hint": "Das Bestellsystem bleibt maßgeblich. Besetzungen werden von einem Koordinator im Web fortgeschrieben; hier wird keine geändert.", + "deployments_home_hint": "Externe Ressourcenaufträge, die Ihre Abteilung besetzt", + "drafts": "Entwürfe", + "field_required": "Dieses Feld ist erforderlich.", + "fill_status_accepted": "Angenommen", + "fill_status_assigned": "Zugewiesen", + "fill_status_checked_in": "Eingecheckt", + "fill_status_declined": "Abgelehnt", + "fill_status_demobilized": "Demobilisiert", + "fill_status_mobilized": "Mobilisiert", + "fill_status_released": "Freigegeben", + "fill_status_requested": "Angefragt", + "fill_status_returned": "Zurückgekehrt", + "fills": "Anfragen und Besetzungen", + "finalize": "Abschließen", + "finalized": "Abgeschlossen.", + "fix_fields": "Einige Felder benötigen noch Aufmerksamkeit.", + "forbidden": "Nur für Abteilungsadministratoren", + "granted": "Erteilt", + "hide_closed": "Abgeschlossene Einsätze ausblenden", + "include_closed": "Abgeschlossene Einsätze anzeigen", + "last_error": "Letzter Fehler", + "last_read": "Zuletzt gelesen", + "last_success": "Letzter Erfolg", + "load_failed": "Dieser Datensatz konnte nicht geladen werden.", + "loading": "Datensätze werden geladen…", + "location_kept": "Standort behalten", + "location_removed": "Standort entfernt", + "manage_on_web": "Konnektoren werden im Web angelegt, geändert und mit Zugangsdaten versehen.", + "minutes": "{{count}} Min.", + "needed_on": "Benötigt", + "needs_attention": "Benötigt Sie", + "never_read": "Noch nie gelesen", + "new_record": "Neuer Datensatz", + "no_assignments": "Ihnen ist nichts zugewiesen.", + "no_attachments": "Noch keine Anhänge.", + "no_connectors": "Keine Konnektoren", + "no_deployments": "Keine Einsätze", + "no_drafts": "Sie haben keine Entwürfe.", + "no_fills": "Keine Anfragen zu diesem Auftrag", + "no_recent": "Keine aktuellen Datensätze.", + "no_reconciliation": "Keine Abweichungen zwischen Quelle und Ihren Datensätzen", + "no_returned": "Ihnen wurde nichts zurückgegeben.", + "no_rows": "Noch keine Zeilen.", + "no_runs": "Noch keine Ausführungen", + "not_found": "Dieser Datensatz ist nicht verfügbar.", + "not_granted": "Nicht erteilt", + "not_sent_yet": "Wartet auf Versand", + "nothing_to_create": "Von hier kann derzeit nichts gestartet werden.", + "offices": "Stellen", + "offline_capable": "Funktioniert offline", + "online_only": "Dieses Formular wird nur online ausgefüllt.", + "online_only_badge": "Nur online", + "open_artifact": "Quellartefakt öffnen", + "open_record": "Datensatz öffnen", + "pending": "Nicht gesendet", + "poll_interval": "Abfrageintervall", + "preview": "Vorschau", + "purpose_acknowledge": "Diesen Datensatz bestätigen", + "purpose_attach": "Anhang hinzufügen", + "purpose_complete": "Diesen Datensatz vervollständigen", + "purpose_correct": "Diesen Datensatz korrigieren", + "purpose_review": "Diesen Datensatz prüfen", + "read_authority": "Leseberechtigung", + "read_feed_now": "Feed jetzt lesen", + "reading_feed": "Wird gelesen…", + "reason_app_disabled": "Datensätze sind für diese App in Ihrer Abteilung nicht eingeschaltet.", + "reason_app_version_too_old": "Aktualisieren Sie die App, um Datensätze zu verwenden.", + "reason_module_disabled": "Datensätze sind für Ihre Abteilung nicht verfügbar.", + "reason_not_member": "Sie sind kein aktives Mitglied dieser Abteilung.", + "reason_records_not_usable": "Ihre Abteilung hat Datensätze noch nicht aktiviert.", + "recent": "Zuletzt", + "reconciliation": "Abgleich", + "reconciliation_count": "{{count}} abzugleichen", + "reconciliation_hint": "Wo Quelle und Ihr Datensatz voneinander abweichen. Nichts wird automatisch angewendet; ein Koordinator entscheidet im Web.", + "reconciliation_local_fill_missing_in_source": "Besetzung existiert lokal, aber nicht in der Quelle", + "reconciliation_source_closed_local_open": "Quelle hat den Auftrag geschlossen, aber Besetzungen sind noch offen", + "reconciliation_source_released_local_not_returned": "Quelle hat den Auftrag freigegeben, aber eine Besetzung ist noch nicht zurück", + "reconciliation_source_request_missing_locally": "Anfrage existiert in der Quelle, aber nicht lokal", + "reconciliation_source_status_ahead": "Quellstatus ist der lokalen Besetzung voraus", + "reconciliation_source_status_behind": "Quellstatus liegt hinter der lokalen Besetzung", + "refresh_catalog": "Aktualisieren", + "requests_this_hour": "Anfragen in dieser Stunde", + "restricted": "Eingeschränkt", + "returned": "An Sie zurückgegeben", + "run_counts": "Gesehen {{seen}} · erstellt {{created}} · Schnappschüsse {{snapshots}} · Anfragen {{requests}} · unverändert {{unchanged}} · abgelehnt {{rejected}} · abzugleichen {{conflicts}}", + "run_failed": "Der Feed konnte nicht gelesen werden", + "run_finished": "Feed gelesen {{outcome}}: {{created}} erstellt, {{snapshots}} Schnappschüsse, {{requests}} Anfragen, {{conflicts}} abzugleichen", + "run_log": "Ausführungsprotokoll", + "run_not_ok": "Feed-Lesevorgang endete mit {{outcome}}", + "run_outcome_disabled": "Deaktiviert", + "run_outcome_failed": "Fehlgeschlagen", + "run_outcome_ok": "OK", + "run_outcome_rate_limited": "Anfragelimit erreicht", + "run_outcome_rejected": "Abgelehnt", + "run_trigger_inbound": "Von Quelle übermittelt", + "run_trigger_manual": "Manuell", + "run_trigger_poll": "Geplant", + "save": "Speichern", + "save_and_send": "Speichern und senden", + "save_failed": "Das konnte nicht gespeichert werden.", + "save_locally": "Auf diesem Gerät behalten", + "saved": "Gespeichert.", + "saved_locally": "Auf diesem Gerät gespeichert. Wird gesendet, sobald Sie wieder online sind.", + "sign_statement": "Ich unterzeichne diesen Datensatz als Autor", + "source": "Quelle", + "source_says": "Quelle:", + "submit_for_review": "Zur Prüfung einreichen", + "submitted": "Zur Prüfung gesendet.", + "take_photo": "Foto aufnehmen", + "terms": "Bedingungen der Quelle", + "terms_acknowledged": "Bestätigt", + "terms_not_acknowledged": "Noch nicht bestätigt", + "title": "Datensätze", + "trainee": "Auszubildende(r)", + "unavailable": "Datensätze sind in dieser App derzeit nicht verfügbar.", + "unit_code": "Einheit", + "unsupported_definition": "Dieses Formular verwendet ein Feld, das diese App-Version nicht ausfüllen kann. Aktualisieren Sie die App oder verwenden Sie die Web-App.", + "unsupported_field": "Diese App kann dieses Feld noch nicht anzeigen. Verwenden Sie die Web-App zum Ausfüllen.", + "untitled": "Ohne Titel", + "update_required": "App-Update erforderlich", + "write_authority": "Schreibberechtigung", + "write_refused": "Verweigert – nichts wird zurückgeschrieben", + "your_record_says": "Ihr Datensatz:" + }, "roles": { "assignedElsewhere": "In einer anderen Rolle", "clearAssignment": "Aktuelle Zuweisung entfernen", @@ -1117,6 +1731,17 @@ "version": "Version", "website": "Website" }, + "site_info": { + "additional_contact": "Weiterer Kontakt", + "alert_notes": "Warnhinweise", + "empty": "Keine Objektinformationen", + "empty_description": "Mit diesem Einsatz sind keine Kontakte mit Einsatzplan, Gefahren, Warnhinweisen oder Dateien verknüpft.", + "entrance": "Zugang", + "files": "Objektdateien", + "load_failed": "Objektinformationen konnten nicht geladen werden.", + "loading": "Objektinformationen werden geladen...", + "primary_contact": "Hauptkontakt" + }, "status": { "add_note": "Notiz hinzufügen", "both_destinations_enabled": "Kann auf Anrufe oder Stationen reagieren", @@ -1157,6 +1782,7 @@ "map": "Karte", "notes": "Notizen", "protocols": "Protokolle", + "records": "Datensätze", "routes": "Routen", "settings": "Einstellungen", "weather_alerts": "Wetter" @@ -1277,5 +1903,80 @@ "unknown": "Unbekannt" } }, - "welcome": "Willkommen bei Resgrid Unit app site" + "welcome": "Willkommen bei Resgrid Unit app site", + "inventory": { + "title": "Bestand", + "titleFor": "Bestand · {{name}}", + "selectUnit": "Wählen Sie zuerst Ihr Fahrzeug.", + "unnamedLocation": "Geräteraum", + "unnamedItem": "Artikel", + "countName": "Kontrolle {{name}} {{date}}", + "notMigrated": "Der Bestand wird für Ihre Organisation eingerichtet.", + "disabled": "Der Bestand ist für Ihre Organisation ausgeschaltet.", + "noLocations": "Diesem Fahrzeug ist kein Bestand zugeordnet.", + "startCount": "Bestand zählen", + "wholeUnit": "Ganzes Fahrzeug: {{name}}", + "pickLocation": "Wo gezählt wird", + "startHelp": "Zählen Sie, was tatsächlich auf dem Fahrzeug ist. Abweichungen werden beim Abschließen gebucht.", + "start": "Zählung starten", + "cannotCount": "Zum Zählen ist die Berechtigung „Bestand anpassen“ für dieses Fahrzeug nötig.", + "counts": "Letzte Zählungen", + "count": "Bestandszählung", + "empty": "Hier ist nichts.", + "serial": "Seriennr. {{serial}}", + "quantity": "× {{quantity}}", + "back": "Zurück", + "completeTitle": "Zählung abschließen?", + "completeBody": "{{count}} Artikel weichen vom Soll ab. Beim Abschließen werden die Abweichungen gebucht.", + "completeNoChanges": "Alles entspricht dem Soll.", + "cancel": "Abbrechen", + "complete": "Abschließen", + "cancelTitle": "Diese Zählung abbrechen?", + "cancelBody": "Bisher Gezähltes wird nicht gebucht.", + "keep": "Weiter zählen", + "cancelCount": "Zählung abbrechen", + "progress": "{{counted}} von {{total}} gezählt", + "differences": "{{count}} Abweichungen", + "awaitingWitness": "Ein kontrollierter Artikel hat sich geändert; eine zweite Person muss diese Zählung im Web bezeugen.", + "completed": "Zählung abgeschlossen.", + "expected": "Soll {{quantity}} {{unit}}", + "present": "Vorhanden", + "missing": "Fehlt", + "less": "Eins weniger", + "more": "Eins mehr", + "matches": "Stimmt", + "save": "Zwischenstand speichern", + "countStatus": { + "0": "In Arbeit", + "1": "Wartet auf Zeugen", + "2": "Abgeschlossen", + "3": "Abgebrochen" + }, + "assetStatus": { + "0": "Im Dienst", + "1": "Ausgegeben", + "2": "In Reparatur", + "3": "Beschädigt", + "4": "Verloren", + "5": "Verbraucht", + "6": "Ausgemustert" + }, + "errors": { + "retry": "Server nicht erreichbar. Versuchen Sie es erneut.", + "locked": "Entsperren Sie geschützte Daten, um fortzufahren.", + "PermissionRequired": "Dazu fehlt Ihnen die Berechtigung.", + "MembershipRequired": "Sie sind kein aktives Mitglied dieser Organisation.", + "InventoryDisabled": "Der Bestand ist für Ihre Organisation ausgeschaltet.", + "MigrationRequired": "Der Bestand wird für Ihre Organisation eingerichtet.", + "CountScopeTooLarge": "Zu viele Artikel für eine Zählung. Zählen Sie einen Geräteraum nach dem anderen.", + "CountEmpty": "Hier gibt es nichts zu zählen.", + "CountSnapshotChanged": "Der Bestand dieses Fahrzeugs hat sich während der Zählung geändert. Brechen Sie ab und beginnen Sie neu.", + "CountStateConflict": "Diese Zählung wurde auf einem anderen Gerät geändert. Öffnen Sie sie erneut.", + "ReturnAssetFirst": "Ein als fehlend markierter Artikel ist noch jemandem ausgegeben. Nehmen Sie ihn zuerst im Web zurück.", + "ProtectedDataRequired": "Entsperren Sie geschützte Daten, um fortzufahren.", + "CountAlreadyExists": "Diese Zählung gibt es bereits.", + "LocationUnavailable": "Dieser Ort ist nicht verfügbar.", + "InvalidCount": "Prüfen Sie die gezählten Werte und versuchen Sie es erneut." + } + } } diff --git a/src/translations/el.json b/src/translations/el.json index ba1f7179..7c4b81be 100644 --- a/src/translations/el.json +++ b/src/translations/el.json @@ -126,6 +126,12 @@ "title": "Σημειώσεις Κλήσης" }, "call_detail": { + "activity_link": { + "auto": "Αυτόματη σύνδεση", + "auto_hint": "Στάλθηκε χωρίς κλήση. Το Resgrid το συνέδεσε από την προηγούμενη κατάσταση ή την αποστολή.", + "inferred": "Συναγόμενο", + "inferred_hint": "Ορίστηκε χωρίς προορισμό από μονάδα ή άτομο που είχε σταλεί σε αυτή την κλήση ενώ εργαζόταν σε αυτήν." + }, "address": "Διεύθυνση", "call_location": "Τοποθεσία Κλήσης", "close_call": "Κλείσιμο Κλήσης", @@ -201,6 +207,7 @@ "dispatched": "Απεσταλμένες", "info": "Πληροφορίες", "protocols": "Πρωτόκολλα", + "site": "Πληροφορίες χώρου", "timeline": "Δραστηριότητα" }, "timestamp": "Χρονική Σήμανση", @@ -228,6 +235,9 @@ "contact_name_placeholder": "Εισαγάγετε το όνομα της επαφής", "contact_phone": "Τηλέφωνο Επαφής", "contact_phone_placeholder": "Εισαγάγετε το τηλέφωνο της επαφής", + "contact_picker": { + "linked": "Συνδεδεμένη επαφή: {{name}}" + }, "coordinates": "Συντεταγμένες GPS", "coordinates_found": "Οι συντεταγμένες βρέθηκαν και η διεύθυνση ενημερώθηκε", "coordinates_geocoding_error": "Αποτυχία λήψης διεύθυνσης για τις συντεταγμένες, αλλά η τοποθεσία ορίστηκε στον χάρτη", @@ -445,6 +455,206 @@ "type_sector_rotation": "Εναλλαγή Τομέα", "type_unit": "Μονάδα" }, + "checklists": { + "errors": { + "conflict": "Ο έλεγχος άλλαξε στον διακομιστή. Τα τοπικά δεδομένα διατηρούνται για επανεξέταση.", + "denied": "Η πρόσβαση δεν είναι διαθέσιμη. Τα κρυπτογραφημένα τοπικά δεδομένα διατηρούνται.", + "locked": "Ξεκλειδώστε τα προστατευμένα δεδομένα για να συνεχίσετε.", + "offline": "Εκτός σύνδεσης: εμφανίζονται έλεγχοι αποθηκευμένοι με ασφάλεια.", + "retry": "Ο συγχρονισμός δεν ολοκληρώθηκε. Τα τοπικά δεδομένα διατηρούνται.", + "storage_full": "Συμπληρώθηκε το τοπικό όριο προχείρων ή αποδεικτικών.", + "validation": "Ελέγξτε τις απαιτούμενες απαντήσεις, ημερομηνίες, σημειώσεις και αποδεικτικά και δοκιμάστε ξανά." + }, + "labels": { + "Actions": "Ενέργειες", + "Active": "Ενεργή", + "AnchorMonth": "Μήνας αναφοράς", + "Annual": "Ετησίως", + "Answer": "Απάντηση", + "Answered": "Απαντημένο", + "AssetUnavailable": "Ο εξοπλισμός με σειριακό αριθμό δεν είναι διαθέσιμος.", + "Assignment": "Ανάθεση σε", + "AssignmentAutomatic": "Αυτόματη ανάθεση", + "AssignmentPermission": "Δεν σας έχει ανατεθεί η εκτέλεση αυτού του ελέγχου.", + "AssignmentType1": "Άτομο", + "AssignmentType2": "Ρόλος προσωπικού", + "AssignmentType3": "Ομάδα ή σταθμός", + "AssignmentType4": "Πλήρωμα μονάδας", + "AssignmentUnavailable": "Επιλέξτε μια διαθέσιμη ανάθεση σε αυτόν τον οργανισμό.", + "Attest": "Βεβαίωση και ολοκλήρωση", + "Attestation": "Δήλωση ανεξάρτητης επαλήθευσης", + "Author": "Συντάκτης", + "AwaitingWitness": "Αναμονή μάρτυρα", + "Back": "Πίσω", + "CalendarInclude": "Έλεγχοι ετοιμότητας", + "CalendarRange": "Επιλέξτε περίοδο ημερολογίου έως 93 ημέρες.", + "Cancelled": "Ακυρωμένος", + "Category": "Κατηγορία", + "Checkbox": "Πλαίσιο ελέγχου", + "Checked": "Επιλεγμένο", + "ChecklistCompleted": "Η λίστα ελέγχου ολοκληρώθηκε", + "ChecklistFailed": "Η λίστα ελέγχου απέτυχε", + "ChecklistMissed": "Η λίστα ελέγχου δεν εκτελέστηκε", + "ChecklistOccurrenceSkipped": "Η παράλειψη του προγραμματισμένου ελέγχου δικαιολογήθηκε", + "ChecklistScheduleChanged": "Το πρόγραμμα ελέγχου άλλαξε", + "Checklists": "Λίστες ελέγχου", + "Checks": "Έλεγχοι", + "Choose": "Επιλέξτε", + "Critical": "Κρίσιμο", + "Daily": "Καθημερινά", + "Date": "Ημερομηνία", + "DayOfMonth": "Ημέρα του μήνα", + "DeleteDraft": "Διαγραφή πρόχειρου", + "Department": "Τμήμα", + "Draft": "Πρόχειρο", + "DraftGuidance": "Αποθηκεύστε ένα πρόχειρο και δημοσιεύστε το. Οι υπάρχουσες εκτελέσεις διατηρούν την αρχική έκδοση. Μην εισάγετε δεδομένα ασθενών.", + "DueChecks": "Εκκρεμείς έλεγχοι", + "DueGuidance": "Προσεχείς έλεγχοι επτά ημερών και ανολοκλήρωτοι έλεγχοι. Οι εκπρόθεσμοι έλεγχοι μπορούν ακόμη να ολοκληρωθούν.", + "EditChecklist": "Επεξεργασία λίστας", + "EditDraft": "Επεξεργασία πρόχειρου", + "EditSchedule": "Επεξεργασία προγράμματος", + "Evidence": "Αποδεικτικό", + "ExcuseCheck": "Δικαιολόγηση παράλειψης", + "Export": "Εξαγωγή JSON", + "Facility": "Εγκατάσταση", + "Fail": "Αποτυχία", + "FailNote": "Μια αποτυχημένη δοκιμή απαιτεί σημείωση.", + "Failed": "Αποτυχία", + "Free": "Οι λίστες είναι δωρεάν για όλα τα τμήματα.", + "Frequency": "Συχνότητα", + "Guidance": "Προσαρμόστε τα πρότυπα στον εξοπλισμό, τις διαδικασίες του κατασκευαστή και τις τοπικές απαιτήσεις πριν από τη χρήση.", + "History": "Ιστορικό", + "InProgress": "Σε εξέλιξη", + "Incomplete": "Ελλιπές", + "InventoryAsset": "Εξοπλισμός με σειριακό αριθμό", + "Item": "Στοιχείο", + "Maintenance": "Συντήρηση και εντολές εργασίας (Readiness Pro)", + "Missed": "Δεν εκτελέστηκε", + "MonthGuidance": "Για μηνιαίους ή αραιότερους ελέγχους. Στους σύντομους μήνες χρησιμοποιείται η τελευταία ημέρα. Ο μήνας αναφοράς καθορίζει τριμηνιαίους, εξαμηνιαίους και ετήσιους ελέγχους.", + "Monthly": "Μηνιαία", + "Name": "Όνομα", + "NewChecklist": "Νέα λίστα", + "NewSchedule": "Νέο πρόγραμμα", + "Next": "Επόμενο", + "No": "Όχι", + "NoDefinitions": "Δεν υπάρχουν λίστες σε αυτή τη σελίδα.", + "NoDueChecks": "Δεν υπάρχουν έλεγχοι σε αυτή τη λίστα.", + "NoResults": "Δεν βρέθηκαν σχετικά πρότυπα.", + "NoSchedules": "Δεν υπάρχουν ακόμη προγράμματα.", + "NoScore": "Χωρίς εφαρμόσιμη βαθμολογία", + "Note": "Σημείωση", + "OccurrenceUnavailable": "Αυτός ο έλεγχος δεν είναι διαθέσιμος ή έχει ήδη αλλάξει.", + "OpenCheck": "Άνοιγμα ελέγχου", + "Optional": "Προαιρετικό", + "Other": "Άλλο", + "Pass": "Επιτυχία", + "Passed": "Επιτυχία", + "PerShift": "Ανά βάρδια", + "Personnel": "Προσωπικό", + "Photo": "Φωτογραφία", + "Preview": "Προεπισκόπηση προτύπου", + "Previous": "Προηγούμενο", + "Print": "Εκτύπωση", + "PublishDraft": "Δημοσίευση πρόχειρου", + "PublishGuidance": "Η δημοσίευση δημιουργεί αμετάβλητη έκδοση για νέες εκτελέσεις.", + "Published": "Δημοσιευμένη", + "PublishedVersion": "Δημοσιευμένη έκδοση", + "Quantity": "Ποσότητα", + "Quarterly": "Τριμηνιαία", + "ReminderBefore": "Λεπτά πριν από την προθεσμία (0 για απενεργοποίηση)", + "ReminderDigest": "Ομαδοποίηση εκκρεμών υπενθυμίσεων σε συνόψεις", + "ReminderEnabled": "Ενεργοποίηση υπενθυμίσεων λιστών ελέγχου", + "ReminderEscalate": "Ειδοποίηση των διαχειριστών του τμήματος μετά από τόσα λεπτά καθυστέρησης (κενό για απενεργοποίηση)", + "ReminderFixedTime": "Ώρα ημερήσιας σύνοψης (ζώνη ώρας οργανισμού· υπερισχύει της έναρξης βάρδιας)", + "ReminderHelp": "Οι υπενθυμίσεις αφορούν ελέγχους που ξεκινούν μετά την ενεργοποίησή τους. Τα μηνύματα περιέχουν σύνδεσμο σύνδεσης και σέβονται τις προτιμήσεις ειδοποιήσεων κάθε ατόμου. Οι συνόψεις ομαδοποιούν τις εκκρεμείς υπενθυμίσεις ανά παραλήπτη.", + "ReminderMessage": "Υπάρχουν ενημερώσεις για τις λίστες ελέγχου. Συνδεθείτε για να δείτε τους ελέγχους σας.", + "ReminderMissed": "Ειδοποίηση του υπεύθυνου προσωπικού για ελέγχους που παραλείφθηκαν", + "ReminderSettings": "Υπενθυμίσεις λιστών ελέγχου", + "ReminderShiftStart": "Αποστολή σύνοψης στην έναρξη βάρδιας", + "ReminderTimeValidation": "Εισαγάγετε μία ώρα σε μορφή HH:mm.", + "ReminderTitle": "Υπενθυμίσεις λιστών ελέγχου", + "ReminderValidation": "Ορίστε διάστημα υπενθύμισης 0–10080 λεπτών και διάστημα ειδοποίησης διαχειριστών 1–43200 λεπτών ή αφήστε το δεύτερο κενό.", + "Remove": "Αφαίρεση", + "ReportedLocation": "Δηλωμένη τοποθεσία", + "Required": "Απαιτείται", + "Result": "Αποτέλεσμα", + "Retire": "Απόσυρση", + "Retired": "Αποσυρμένη", + "RunGuidance": "Απαντήστε σε κάθε σχετικό στοιχείο. Το μη εφαρμόσιμο απαιτεί αιτιολογία. Αποθηκεύστε την πρόοδο για συνέχεια αργότερα. Οι υποβληθείσες απαντήσεις και αποδείξεις δεν αλλάζουν.", + "SaveDraft": "Αποθήκευση πρόχειρου", + "SaveProgress": "Αποθήκευση προόδου", + "SaveReminders": "Αποθήκευση ρυθμίσεων υπενθυμίσεων", + "SaveSchedule": "Αποθήκευση προγράμματος", + "ScheduleEnabled": "Ενεργό πρόγραμμα", + "ScheduleEndDate": "Ημερομηνία λήξης (προαιρετική)", + "ScheduleNotes": "Σημειώσεις προγράμματος", + "SchedulePaused": "Σε παύση", + "SchedulePermission": "Δεν έχετε δικαίωμα διαχείρισης προγραμμάτων ελέγχου.", + "ScheduleRequiresPublished": "Δημοσιεύστε τη λίστα ελέγχου πριν την προγραμματίσετε.", + "ScheduleStartDate": "Ημερομηνία έναρξης", + "ScheduleTimeGuidance": "Οι ώρες ακολουθούν αυτή τη ζώνη. Ώρα που παραλείπεται μεταφέρεται εμπρός· επαναλαμβανόμενη ώρα εκτελείται μία φορά.", + "ScheduleUnavailable": "Αυτό το πρόγραμμα δεν είναι διαθέσιμο.", + "ScheduleValidation": "Ελέγξτε τις ημερομηνίες, τις ώρες, τον στόχο και την επανάληψη του προγράμματος.", + "ScheduleVersionGuidance": "Η αποθήκευση δεσμεύει την τρέχουσα δημοσιευμένη έκδοση. Οι αλλαγές αντικαθιστούν μελλοντικούς ελέγχους που δεν ξεκίνησαν· η παύση ακυρώνει ελέγχους που δεν ξεκίνησαν.", + "ScheduleWorkshift": "Βάρδια για ελέγχους ανά βάρδια", + "Scheduled": "Προγραμματισμένος", + "ScheduledFor": "Προγραμματισμένος για", + "Schedules": "Προγράμματα", + "Score": "Βαθμολογία", + "Search": "Αναζήτηση", + "SearchLabel": "Εύρεση προτύπου", + "SelectTarget": "Επιλέξτε στόχο", + "SemiAnnual": "Ανά εξάμηνο", + "Signature": "Υπογραφή", + "SkipReason": "Αιτία δικαιολόγησης της παράλειψης", + "SkipReasonRequired": "Εισαγάγετε αιτία έως 4.000 χαρακτήρες.", + "Skipped": "Δικαιολογημένη παράλειψη", + "StartRun": "Έναρξη λίστας", + "Started": "Έναρξη", + "State": "Κατάσταση", + "SubmitRun": "Υποβολή λίστας", + "Submitted": "Υποβλήθηκε", + "Target": "Στόχος", + "Templates": "Πρότυπα λιστών", + "TimeZone": "Ζώνη ώρας", + "TimesOfDay": "Τοπικές ώρες", + "TimesOfDayGuidance": "Εισαγάγετε έως οκτώ ώρες σε μορφή 24ώρου, χωρισμένες με κόμματα.", + "Unanswered": "Αναπάντητο", + "Unchecked": "Μη επιλεγμένο", + "Unit": "Μονάδα", + "Units": "Μονάδες", + "Version": "Έκδοση", + "Weekly": "Εβδομαδιαία", + "WeeklyDays": "Ημέρες εβδομαδιαίων ελέγχων", + "WindowEnds": "Λήξη περιθωρίου", + "WindowMinutes": "Περιθώριο ολοκλήρωσης (λεπτά)", + "Witness": "Ο συντάκτης και ένα διαφορετικό μέλος, με επαληθευμένη την ταυτότητα και των δύο, πρέπει να επαληθεύσουν ανεξάρτητα το αποτέλεσμα.", + "WitnessInstructions": "Ένα διαφορετικό μέλος με επαληθευμένη ταυτότητα πρέπει να ελέγξει ανεξάρτητα τις υποβληθείσες απαντήσεις και τα αποδεικτικά. Μοιραστείτε μαζί του τον σύνδεσμο αυτής της σελίδας.", + "WitnessLabel": "Μάρτυρας", + "WorkshiftFallback": "Χρήση τοπικών ωρών κάθε ημέρα", + "WorkshiftGuidance": "Μόνο για ελέγχους ανά βάρδια. Η επιλεγμένη βάρδια χρησιμοποιεί τις καταγεγραμμένες ημερομηνίες έναρξης· διαφορετικά ισχύουν καθημερινά οι τοπικές ώρες.", + "Yes": "Ναι" + }, + "ui": { + "calendar": "Ημερολόγιο", + "clearSignature": "Καθαρισμός υπογραφής", + "close": "Κλείσιμο", + "confirm": "Αντικατάσταση", + "editLocal": "Επεξεργασία τοπικού προχείρου", + "localDrafts": "Τοπικά πρόχειρα", + "notApplicable": "Δεν εφαρμόζεται", + "prepareOffline": "Προετοιμασία για χρήση εκτός σύνδεσης", + "queued": "Αποθηκεύτηκε με ασφάλεια στη συσκευή· αναμονή συγχρονισμού.", + "reason": "Αιτιολογία", + "refresh": "Ανανέωση", + "removeImage": "Αφαίρεση εικόνας", + "replaceLocal": "Αντικατάσταση των τοπικών απαντήσεων και εκκρεμών εικόνων με την έκδοση του διακομιστή;", + "retry": "Επανάληψη συγχρονισμού", + "takePhoto": "Λήψη φωτογραφίας", + "unlock": "Ξεκλείδωμα προστατευμένων δεδομένων", + "useServer": "Χρήση έκδοσης διακομιστή" + } + }, "common": { "add": "Προσθήκη", "back": "Πίσω", @@ -536,6 +746,12 @@ "expires": "Λήγει", "facebook": "Facebook", "faxPhone": "Φαξ", + "files": { + "download_failed": "Δεν ήταν δυνατή η λήψη του αρχείου.", + "empty": "Δεν έχουν επισυναφθεί αρχεία σε αυτή την επαφή.", + "loading": "Φόρτωση αρχείων...", + "share_unavailable": "Η κοινοποίηση δεν είναι διαθέσιμη σε αυτή τη συσκευή." + }, "formError": "Διορθώστε τα σφάλματα στη φόρμα", "homePhone": "Τηλέφωνο Οικίας", "identification": "Ταυτοποίηση", @@ -557,6 +773,48 @@ "otherInfo": "Άλλες Πληροφορίες", "person": "Άτομο", "phone": "Τηλέφωνο", + "preplan": { + "access": "Πρόσβαση", + "access_notes": "Σημειώσεις πρόσβασης", + "alarm_company": "Εταιρεία συναγερμού", + "alarm_company_phone": "Τηλέφωνο εταιρείας συναγερμού", + "alarm_panel": "Πίνακας συναγερμού", + "alert_flag": "Ειδοποίηση", + "construction": "Κατασκευή · στέγη · χρήση", + "electric_shutoff": "Διακοπή ρεύματος", + "emergency_contact": "Επαφή έκτακτης ανάγκης", + "emergency_contact_phone": "Τηλέφωνο επαφής έκτακτης ανάγκης", + "gas_shutoff": "Διακοπή αερίου", + "gate_code": "Κωδικός πύλης", + "general_hazard_notes": "Γενικές σημειώσεις κινδύνων", + "general_hazards": "Κίνδυνοι & τακτική", + "hazards": "Κίνδυνοι του χώρου", + "hazmat_on_site": "Επικίνδυνα υλικά στον χώρο", + "knox_box": "Κλειδοθήκη", + "last_reviewed": "Τελευταία αναθεώρηση", + "loading": "Φόρτωση σχεδίου επέμβασης...", + "location": "Τοποθεσία", + "nearest_hydrant": "Πλησιέστερος κρουνός", + "never_reviewed": "Δεν έχει αναθεωρηθεί ποτέ", + "next_review": "Επόμενη αναθεώρηση", + "none": "Δεν έχει καταγραφεί σχέδιο επέμβασης για αυτή την επαφή.", + "occupancy": "Κατασκευή & χρήση", + "occupancy_hours": "Ώρες λειτουργίας", + "occupancy_notes": "Σημειώσεις χρήσης", + "occupant_load": "Αριθμός ατόμων", + "occupants_needing_assistance": "Ίσως χρειαστεί βοήθεια εκκένωσης για τους ενοίκους", + "on_site_contacts": "Επαφές στον χώρο", + "required_fire_flow": "Απαιτούμενη παροχή", + "review_overdue": "Η ημερομηνία αναθεώρησης του σχεδίου έχει παρέλθει.", + "secondary_contact": "Δευτερεύουσα επαφή", + "secondary_contact_phone": "Τηλέφωνο δευτερεύουσας επαφής", + "tactical_summary": "Τακτική σύνοψη", + "utilities": "Διακοπές παροχών", + "utility_notes": "Σημειώσεις παροχών", + "water_shutoff": "Διακοπή νερού", + "water_supply": "Παροχή νερού", + "water_supply_notes": "Σημειώσεις παροχής νερού" + }, "public": "Δημόσια", "required": "Απαιτείται", "save": "Αποθήκευση Επαφής", @@ -569,7 +827,9 @@ "systemInformation": "Πληροφορίες Συστήματος", "tabs": { "details": "Λεπτομέρειες", - "notes": "Σημειώσεις" + "files": "Αρχεία", + "notes": "Σημειώσεις", + "preplan": "Σχέδιο επέμβασης" }, "threads": "Threads", "title": "Επαφές", @@ -578,7 +838,13 @@ "unknown_person": "Άγνωστο άτομο", "visibility": "Ορατότητα", "website": "Ιστότοπος", - "zip": "Ταχυδρομικός Κώδικας" + "zip": "Ταχυδρομικός Κώδικας", + "withheld": "{{count}} προστατευμένα στοιχεία είναι κρυφά.", + "categoryLabel": "Κατηγορία: {{name}}", + "physicalAddress": "Φυσική διεύθυνση", + "mailingAddress": "Ταχυδρομική διεύθυνση", + "hasGeofence": "Αυτή η τοποθεσία έχει χαρτογραφημένη γεωπεριοχή.", + "customFields": "Προσαρμοσμένα πεδία" }, "data_protection": { "conceal": "Απόκρυψη ξανά", @@ -813,11 +1079,193 @@ "select_all": "Επιλογή όλων", "selected_count": "{{count}} επιλεγμένες", "title": "Ειδοποιήσεις", - "unable_to_load": "Δεν ήταν δυνατή η φόρτωση των ειδοποιήσεων" + "unable_to_load": "Δεν ήταν δυνατή η φόρτωση των ειδοποιήσεων", + "detail_title": "Ειδοποίηση", + "additional_info": "Πρόσθετες Πληροφορίες", + "view_call": "Προβολή Κλήσης", + "view_chat": "Προβολή συνομιλίας", + "open_reference": "Άνοιγμα" }, "onboarding": { "message": "Καλώς ήρθατε στον ιστότοπο της εφαρμογής Resgrid Unit" }, + "operations": { + "all": "Όλα", + "back": "Πίσω", + "disabled": "Οι αποστολές δεν είναι ενεργοποιημένες για αυτό το τμήμα.", + "entryType": { + "0": "Αποστολή", + "1": "Αναμονή", + "2": "Μετακίνηση" + }, + "errors": { + "denied": "Δεν έχετε πρόσβαση σε αυτήν την αποστολή.", + "disabled": "Αυτή η λειτουργία είναι απενεργοποιημένη για το τμήμα σας.", + "locked": "Ξεκλειδώστε τα προστατευμένα δεδομένα για να συνεχίσετε.", + "retry": "Δεν ήταν δυνατή η σύνδεση με τον διακομιστή. Δοκιμάστε ξανά.", + "validation": "Ο διακομιστής απέρριψε την αλλαγή.", + "timereports_subject_covered": "Αυτό το πλήρωμα ή πρόσωπο έχει ήδη χρόνο σε άλλη αναφορά για αυτή την ημέρα.", + "timereports_date_exists": "Υπάρχει ήδη αναφορά για αυτό το πλήρωμα ή πρόσωπο εκείνη την ημέρα.", + "timereports_scope_invalid": "Αυτό το πλήρωμα ή πρόσωπο δεν είναι ενεργό σε αυτή την ανάπτυξη.", + "timereports_locked": "Αυτή η αναφορά δεν μπορεί πλέον να αλλάξει.", + "timereports_time_invalid": "Εισαγάγετε ώρες ως HH:mm.", + "deployments_closed": "Αυτή η ανάπτυξη έχει κλείσει.", + "expenses_receipt_invalid": "Η απόδειξη δεν μπόρεσε να διαβαστεί.", + "deployments_attachment_too_large": "Η απόδειξη είναι πολύ μεγάλη." + }, + "financeMode": { + "0": "Επιχειρησιακό", + "1": "Ανάκτηση κόστους", + "2": "Χρεώσιμο" + }, + "issues": { + "end_before_start": "Η ώρα λήξης πρέπει να είναι μετά την ώρα έναρξης.", + "missing_subject": "Επιλέξτε σε ποιον ή σε τι αφορά αυτή η καταχώριση.", + "missing_time": "Εισαγάγετε ώρα έναρξης και λήξης.", + "overlap": "Αυτή η καταχώριση επικαλύπτει άλλη για το ίδιο άτομο ή μονάδα.", + "subject_outside_report_scope": "Αυτή η καταχώριση αφορά κάποιον εκτός του πληρώματος ή του προσώπου αυτής της αναφοράς.", + "subject_on_other_report": "Αυτό το πρόσωπο ή η μονάδα έχει ήδη χρόνο σε άλλη αναφορά την ίδια ημέρα.", + "subject_not_on_roster": "Αυτή η καταχώριση αφορά κάποιον που δεν είναι στη σύνθεση.", + "entries_overlap": "Δύο καταχωρίσεις του ίδιου προσώπου ή μονάδας επικαλύπτονται.", + "no_entries": "Προσθέστε τουλάχιστον μία καταχώριση.", + "break_rule": "Μια μεγάλη βάρδια δεν έχει καταγεγραμμένο διάλειμμα.", + "long_travel": "Περισσότερος χρόνος μετακίνησης από όσο επιτρέπεται σε μία ημέρα· χρειάζεται έγκριση.", + "outside_report_date": "Αυτή η καταχώριση είναι εκτός της ημερομηνίας της αναφοράς." + }, + "mars": { + "counts": "{{errors}} σφάλματα · {{warnings}} προειδοποιήσεις", + "draft": "Προσχέδιο F-42 από τις αναφορές χρόνου", + "f42": "F-42", + "help": "Το F-42 συντάσσεται από τις υποβληθείσες αναφορές χρόνου και ελέγχεται με τους κανόνες της πύλης CAL OES. Η υποβολή παραμένει χειροκίνητο, ελεγχόμενο βήμα στο web.", + "none": "Δεν υπάρχουν ακόμη στοιχεία MARS για αυτήν την αποστολή.", + "notReady": "Δεν είναι έτοιμο για την πύλη", + "ready": "Έτοιμο για την πύλη", + "record": "MARS {{id}} · {{status}}", + "title": "F-42", + "validate": "Επικύρωση" + }, + "noNumbers": "Χωρίς αριθμούς συμβάντος ή αιτήματος", + "none": "Δεν υπάρχουν αποστολές.", + "open": "Ανοικτές", + "phase": { + "0": "Κινητοποίηση", + "1": "Αναμονή", + "2": "Συμβάν", + "3": "Επιστροφή" + }, + "reportStatus": { + "0": "Πρόχειρο", + "1": "Υποβλήθηκε", + "2": "Εγκρίθηκε", + "3": "Χρεώθηκε", + "4": "Άκυρο" + }, + "roster": "{{units}} μονάδες · {{personnel}} άτομα", + "status": { + "0": "Προγραμματισμένη", + "1": "Αναμονή", + "2": "Ενεργή", + "3": "Αποκινητοποίηση", + "4": "Ολοκληρώθηκε", + "5": "Ακυρώθηκε" + }, + "time": { + "accommodation": "Διαμονή από την υπηρεσία", + "addEntry": "Προσθήκη καταχώρισης", + "from": "Από", + "hours": "{{hours}} ώ", + "meals": "Γεύματα από την υπηρεσία", + "noEntries": "Δεν υπάρχουν καταχωρίσεις ακόμη.", + "noReport": "Δεν υπάρχει αναφορά χρόνου για αυτήν την ημέρα.", + "notes": "Σημειώσεις", + "remove": "Αφαίρεση", + "report": "Αναφορά #{{number}}", + "save": "Αποθήκευση καταχωρίσεων", + "start": "Έναρξη αναφοράς χρόνου", + "subject": "Ποιος ή τι", + "submit": "Υποβολή αναφοράς", + "title": "Χρόνος", + "to": "Έως", + "total": "Σύνολο {{hours}} ώ", + "type": "Τύπος καταχώρισης", + "unpaidBreak": "Μη αμειβόμενο διάλειμμα (λεπτά)", + "crewReport": "Αναφορά χρόνου πληρώματος #{{number}} · {{name}}", + "individualReport": "Αναφορά χρόνου #{{number}} · {{name}}", + "noCrewReport": "Δεν υπάρχει αναφορά χρόνου πληρώματος για {{name}} αυτή την ημέρα.", + "coveredBy": "Αυτός ο χρόνος υπάρχει ήδη στην αναφορά #{{number}} για αυτή την ημέρα.", + "startCrew": "Έναρξη αναφοράς χρόνου πληρώματος", + "readOnly": "Μπορείτε να δείτε αυτή την αναφορά αλλά όχι να την αλλάξετε.", + "nextDay": "Λήγει την επόμενη ημέρα", + "applyToCrew": "Εφαρμογή στο πλήρωμα", + "approve": "Έγκριση αναφοράς", + "dayReports": "Αναφορές αυτής της ημέρας", + "awaitingApproval": "Αναμένει έγκριση ({{count}})", + "notOnRoster": "Δεν είστε στη σύνθεση ή στο πλήρωμα αυτής της ανάπτυξης." + }, + "title": "Αποστολές", + "usage": { + "add": "Προσθήκη μέτρησης", + "endOdometer": "Χιλιομετρητής τέλους", + "engineHours": "Ώρες κινητήρα", + "fuel": "Καύσιμο", + "needsReview": "Απαιτεί έλεγχο", + "phase": "Φάση", + "saved": "Η μέτρηση αποθηκεύτηκε.", + "startOdometer": "Χιλιομετρητής έναρξης", + "title": "Χρήση", + "unit": "Μονάδα" + }, + "crewOf": "Πλήρωμα του {{names}}", + "previousDay": "Προηγούμενη ημέρα", + "nextDay": "Επόμενη ημέρα", + "scope": { + "deployment": "Ολόκληρη η ανάπτυξη", + "mine": "Ο χρόνος μου", + "crew": "Πλήρωμα: {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "Υπογραφές", + "crewBossSigned": "Υπογράφηκε από τον επικεφαλής πληρώματος {{when}}", + "crewBossPending": "Δεν έχει υπογραφεί ακόμη από τον επικεφαλής πληρώματος.", + "customerSigned": "Υπογράφων φορέα: {{name}}", + "customerPending": "Δεν έχει καταγραφεί υπογραφή φορέα.", + "asCrewBoss": "Υπογραφή ως επικεφαλής πληρώματος", + "customerName": "Όνομα υπογράφοντος φορέα", + "recordCustomer": "Καταγραφή υπογραφής φορέα" + }, + "expenses": { + "title": "Έξοδα", + "type": { + "0": "Ημερήσια αποζημίωση γευμάτων", + "1": "Διαμονή", + "2": "Ιδιωτική διαμονή", + "3": "Πορθμείο", + "4": "Καύσιμα", + "5": "Αναπλήρωση υλικών", + "6": "Άλλο" + }, + "typeLabel": "Τύπος εξόδου", + "amount": "Ποσό ({{currency}})", + "description": "Περιγραφή", + "city": "Πόλη", + "takePhoto": "Λήψη φωτογραφίας", + "choosePhoto": "Επιλογή φωτογραφίας", + "receiptAttached": "Επισυνάφθηκε φωτογραφία απόδειξης.", + "save": "Προσθήκη εξόδου", + "add": "Νέο έξοδο", + "noneToday": "Δεν υπάρχουν έξοδα για αυτή την ημέρα.", + "otherDays": "Άλλες ημέρες", + "hasReceipt": "απόδειξη", + "remove": "Αφαίρεση", + "removeTitle": "Αφαίρεση εξόδου;", + "removeBody": "Αυτό αφαιρεί το έξοδο και την απόδειξή του από την ανάπτυξη.", + "cancel": "Άκυρο", + "saved": "Το έξοδο αποθηκεύτηκε.", + "amountInvalid": "Εισαγάγετε ένα ποσό όπως 42.50.", + "photoDenied": "Επιτρέψτε την πρόσβαση στην κάμερα ή στις φωτογραφίες για να επισυνάψετε απόδειξη." + } + }, "protocols": { "details": { "close": "Κλείσιμο", @@ -846,6 +1294,172 @@ "unknown_type_warning": "Αυτός ο τύπος ειδοποίησης δεν αναγνωρίζεται. Επικοινωνήστε με την υποστήριξη αν συνεχίσετε να βλέπετε αυτό το μήνυμα.", "view_call": "Προβολή Κλήσης" }, + "records": { + "add_row": "Προσθήκη γραμμής", + "agencies": "Υπηρεσίες", + "agreement": "Συμφωνία", + "assigned": "Ανατεθειμένα σε εσάς", + "attachment_failed": "Δεν ήταν δυνατή η αποστολή του συνημμένου.", + "attachment_file_missing": "Το αρχείο δεν υπάρχει πλέον στη συσκευή.", + "attachment_unreadable": "Δεν ήταν δυνατή η ανάγνωση του αρχείου.", + "attachment_will_resume": "Σε παύση. Θα συνεχίσει από εκεί που σταμάτησε.", + "attachments": "Συνημμένα", + "attachments_hint": "Τα συνημμένα προστίθενται μετά την αποθήκευση της εγγραφής.", + "attestation": "Βεβαιώνω ότι αυτή η εγγραφή είναι πλήρης και ακριβής", + "back": "Πίσω", + "busy": "Μια ανάγνωση εκτελείται ήδη", + "camera_denied": "Η κάμερα δεν είναι διαθέσιμη χωρίς άδεια.", + "cancel": "Ακύρωση", + "captured_on": "Καταγράφηκε", + "choose": "Επιλέξτε…", + "choose_file": "Επιλογή αρχείου", + "choose_photo": "Επιλογή φωτογραφίας", + "closed_out_on": "Έκλεισε", + "closeout_notes": "Σημειώσεις κλεισίματος", + "conflict_app_version": "Οι εγγραφές δεν είναι ενεργοποιημένες για αυτήν την έκδοση.", + "conflict_definition_retired": "Αυτή η φόρμα αποσύρθηκε. Ξεκινήστε ξανά από την τρέχουσα λίστα.", + "conflict_etag": "Κάποιος άλλος άλλαξε αυτήν την εγγραφή. Φορτώστε την ξανά και εφαρμόστε τις αλλαγές σας.", + "conflict_permission": "Δεν έχετε πλέον πρόσβαση σε αυτήν την εγγραφή.", + "conflict_protected_data": "Αυτή η φόρμα απαιτεί ενεργή σύνδεση και πρόσβαση σε προστατευμένα δεδομένα.", + "conflict_scope": "Η πρόσβασή σας άλλαξε, οπότε η εγγραφή φορτώθηκε ξανά.", + "connector_disabled": "Ανενεργός", + "connector_maintained": "Συντηρείται από σύνδεσμο", + "connector_not_ready": "Μη έτοιμος", + "connector_ready": "Έτοιμος", + "connectors": "Εξωτερικοί σύνδεσμοι", + "connectors_hint": "Κάθε σύνδεσμος διαβάζει ένα σύστημα παραγγελιών με τους όρους του, κρυπτογραφημένο διαπιστευτήριο και όριο αιτημάτων. Η εξουσιοδότηση εγγραφής απορρίπτεται.", + "connectors_home_hint": "Ροές συστημάτων παραγγελιών και τι χρειάζεται συμφωνία", + "cost_code": "Κωδικός κόστους", + "deployment_status_closed_out": "Κλειστή", + "deployment_status_mobilized": "Κινητοποιημένη", + "deployment_status_open": "Ανοιχτή", + "deployment_status_released": "Αποδεσμευμένη", + "deployments": "Αναπτύξεις", + "deployments_hint": "Το σύστημα παραγγελιών παραμένει αυθεντικό. Οι καλύψεις προωθούνται από συντονιστή στο web· τίποτα εδώ δεν τις αλλάζει.", + "deployments_home_hint": "Εξωτερικές παραγγελίες πόρων που καλύπτει το τμήμα σας", + "drafts": "Πρόχειρα", + "field_required": "Αυτό το πεδίο είναι υποχρεωτικό.", + "fill_status_accepted": "Αποδεκτή", + "fill_status_assigned": "Ανατέθηκε", + "fill_status_checked_in": "Έγινε check-in", + "fill_status_declined": "Απορρίφθηκε", + "fill_status_demobilized": "Αποκινητοποιήθηκε", + "fill_status_mobilized": "Κινητοποιήθηκε", + "fill_status_released": "Αποδεσμεύτηκε", + "fill_status_requested": "Ζητήθηκε", + "fill_status_returned": "Επέστρεψε", + "fills": "Αιτήματα και καλύψεις", + "finalize": "Οριστικοποίηση", + "finalized": "Οριστικοποιήθηκε.", + "fix_fields": "Ορισμένα πεδία χρειάζονται ακόμη προσοχή.", + "forbidden": "Μόνο για διαχειριστές τμήματος", + "granted": "Χορηγήθηκε", + "hide_closed": "Απόκρυψη κλειστών αναπτύξεων", + "include_closed": "Εμφάνιση κλειστών αναπτύξεων", + "last_error": "Τελευταίο σφάλμα", + "last_read": "Τελευταία ανάγνωση", + "last_success": "Τελευταία επιτυχία", + "load_failed": "Δεν ήταν δυνατή η φόρτωση της εγγραφής.", + "loading": "Φόρτωση εγγραφών…", + "location_kept": "Διατηρήθηκε η τοποθεσία", + "location_removed": "Αφαιρέθηκε η τοποθεσία", + "manage_on_web": "Οι σύνδεσμοι δημιουργούνται, αλλάζουν και λαμβάνουν διαπιστευτήρια στο web.", + "minutes": "{{count}} λεπτά", + "needed_on": "Απαιτείται", + "needs_attention": "Χρειάζεται εσάς", + "never_read": "Ποτέ δεν διαβάστηκε", + "new_record": "Νέα εγγραφή", + "no_assignments": "Δεν σας έχει ανατεθεί τίποτα.", + "no_attachments": "Δεν υπάρχουν ακόμη συνημμένα.", + "no_connectors": "Κανένας σύνδεσμος", + "no_deployments": "Καμία ανάπτυξη", + "no_drafts": "Δεν έχετε πρόχειρα.", + "no_fills": "Καμία αίτηση σε αυτήν την παραγγελία", + "no_recent": "Δεν υπάρχουν πρόσφατες εγγραφές.", + "no_reconciliation": "Καμία διαφωνία μεταξύ της πηγής και των εγγραφών σας", + "no_returned": "Δεν σας έχει επιστραφεί τίποτα.", + "no_rows": "Δεν υπάρχουν ακόμη γραμμές.", + "no_runs": "Καμία εκτέλεση ακόμη", + "not_found": "Αυτή η εγγραφή δεν είναι διαθέσιμη.", + "not_granted": "Δεν χορηγήθηκε", + "not_sent_yet": "Αναμονή αποστολής", + "nothing_to_create": "Δεν μπορεί να ξεκινήσει τίποτα από εδώ αυτήν τη στιγμή.", + "offices": "Γραφεία", + "offline_capable": "Λειτουργεί εκτός σύνδεσης", + "online_only": "Αυτή η φόρμα συμπληρώνεται μόνο online.", + "online_only_badge": "Μόνο online", + "open_artifact": "Άνοιγμα τεχνουργήματος πηγής", + "open_record": "Άνοιγμα εγγραφής", + "pending": "Δεν στάλθηκε", + "poll_interval": "Διάστημα ελέγχου", + "preview": "Προεπισκόπηση", + "purpose_acknowledge": "Επιβεβαιώστε αυτήν την εγγραφή", + "purpose_attach": "Προσθέστε συνημμένο", + "purpose_complete": "Ολοκληρώστε αυτήν την εγγραφή", + "purpose_correct": "Διορθώστε αυτήν την εγγραφή", + "purpose_review": "Ελέγξτε αυτήν την εγγραφή", + "read_authority": "Εξουσιοδότηση ανάγνωσης", + "read_feed_now": "Ανάγνωση ροής τώρα", + "reading_feed": "Ανάγνωση…", + "reason_app_disabled": "Οι εγγραφές δεν είναι ενεργές για αυτήν την εφαρμογή στο τμήμα σας.", + "reason_app_version_too_old": "Ενημερώστε την εφαρμογή για να χρησιμοποιήσετε τις εγγραφές.", + "reason_module_disabled": "Οι εγγραφές δεν είναι διαθέσιμες για το τμήμα σας.", + "reason_not_member": "Δεν είστε ενεργό μέλος αυτού του τμήματος.", + "reason_records_not_usable": "Το τμήμα σας δεν έχει ενεργοποιήσει ακόμη τις εγγραφές.", + "recent": "Πρόσφατα", + "reconciliation": "Συμφωνία", + "reconciliation_count": "{{count}} προς συμφωνία", + "reconciliation_hint": "Όπου η πηγή και η εγγραφή σας διαφωνούν. Τίποτα δεν εφαρμόζεται αυτόματα· ένας συντονιστής αποφασίζει στο web.", + "reconciliation_local_fill_missing_in_source": "Η κάλυψη υπάρχει τοπικά αλλά όχι στην πηγή", + "reconciliation_source_closed_local_open": "Η πηγή έκλεισε την παραγγελία, αλλά υπάρχουν ανοιχτές καλύψεις", + "reconciliation_source_released_local_not_returned": "Η πηγή αποδέσμευσε την παραγγελία, αλλά μια κάλυψη δεν έχει επιστρέψει", + "reconciliation_source_request_missing_locally": "Το αίτημα υπάρχει στην πηγή αλλά όχι τοπικά", + "reconciliation_source_status_ahead": "Η κατάσταση της πηγής προηγείται της τοπικής κάλυψης", + "reconciliation_source_status_behind": "Η κατάσταση της πηγής υστερεί της τοπικής κάλυψης", + "refresh_catalog": "Ανανέωση", + "requests_this_hour": "Αιτήματα αυτήν την ώρα", + "restricted": "Περιορισμένο", + "returned": "Επιστράφηκαν σε εσάς", + "run_counts": "Είδαμε {{seen}} · δημιουργήθηκαν {{created}} · στιγμιότυπα {{snapshots}} · αιτήματα {{requests}} · αμετάβλητα {{unchanged}} · απορρίφθηκαν {{rejected}} · προς συμφωνία {{conflicts}}", + "run_failed": "Δεν ήταν δυνατή η ανάγνωση της ροής", + "run_finished": "Η ροή διαβάστηκε {{outcome}}: {{created}} δημιουργήθηκαν, {{snapshots}} στιγμιότυπα, {{requests}} αιτήματα, {{conflicts}} προς συμφωνία", + "run_log": "Ημερολόγιο εκτελέσεων", + "run_not_ok": "Η ανάγνωση της ροής τελείωσε με {{outcome}}", + "run_outcome_disabled": "Ανενεργός", + "run_outcome_failed": "Απέτυχε", + "run_outcome_ok": "Εντάξει", + "run_outcome_rate_limited": "Όριο αιτημάτων", + "run_outcome_rejected": "Απορρίφθηκε", + "run_trigger_inbound": "Στάλθηκε από την πηγή", + "run_trigger_manual": "Χειροκίνητη", + "run_trigger_poll": "Προγραμματισμένη", + "save": "Αποθήκευση", + "save_and_send": "Αποθήκευση και αποστολή", + "save_failed": "Δεν ήταν δυνατή η αποθήκευση.", + "save_locally": "Διατήρηση σε αυτήν τη συσκευή", + "saved": "Αποθηκεύτηκε.", + "saved_locally": "Διατηρήθηκε στη συσκευή. Θα σταλεί όταν επανέλθετε online.", + "sign_statement": "Υπογράφω αυτήν την εγγραφή ως συντάκτης", + "source": "Πηγή", + "source_says": "Πηγή:", + "submit_for_review": "Υποβολή για έλεγχο", + "submitted": "Στάλθηκε για έλεγχο.", + "take_photo": "Λήψη φωτογραφίας", + "terms": "Όροι πηγής", + "terms_acknowledged": "Αποδεκτοί", + "terms_not_acknowledged": "Δεν έχουν γίνει ακόμη αποδεκτοί", + "title": "Εγγραφές", + "trainee": "Εκπαιδευόμενος", + "unavailable": "Οι εγγραφές δεν είναι διαθέσιμες σε αυτήν την εφαρμογή αυτήν τη στιγμή.", + "unit_code": "Μονάδα", + "unsupported_definition": "Αυτή η φόρμα χρησιμοποιεί πεδίο που αυτή η έκδοση δεν μπορεί να συμπληρώσει. Ενημερώστε την εφαρμογή ή χρησιμοποιήστε το web.", + "unsupported_field": "Αυτή η εφαρμογή δεν μπορεί ακόμη να εμφανίσει αυτό το πεδίο. Χρησιμοποιήστε την εφαρμογή web.", + "untitled": "Χωρίς τίτλο", + "update_required": "Απαιτείται ενημέρωση εφαρμογής", + "write_authority": "Εξουσιοδότηση εγγραφής", + "write_refused": "Απορρίφθηκε — τίποτα δεν γράφεται πίσω", + "your_record_says": "Η εγγραφή σας:" + }, "roles": { "assignedElsewhere": "Σε άλλον ρόλο", "clearAssignment": "Καθαρισμός της τρέχουσας ανάθεσης", @@ -1117,6 +1731,17 @@ "version": "Έκδοση", "website": "Ιστότοπος" }, + "site_info": { + "additional_contact": "Πρόσθετη επαφή", + "alert_notes": "Σημειώσεις ειδοποίησης", + "empty": "Δεν υπάρχουν πληροφορίες χώρου", + "empty_description": "Καμία επαφή συνδεδεμένη με αυτή την κλήση δεν έχει σχέδιο επέμβασης, κινδύνους, σημειώσεις ειδοποίησης ή αρχεία.", + "entrance": "Είσοδος", + "files": "Αρχεία χώρου", + "load_failed": "Δεν ήταν δυνατή η φόρτωση των πληροφοριών χώρου.", + "loading": "Φόρτωση πληροφοριών χώρου...", + "primary_contact": "Κύρια επαφή" + }, "status": { "add_note": "Προσθήκη Σημείωσης", "both_destinations_enabled": "Μπορεί να ανταποκριθεί σε κλήσεις ή σταθμούς", @@ -1157,6 +1782,7 @@ "map": "Χάρτης", "notes": "Σημειώσεις", "protocols": "Πρωτόκολλα", + "records": "Εγγραφές", "routes": "Διαδρομές", "settings": "Ρυθμίσεις", "weather_alerts": "Καιρός" @@ -1277,5 +1903,80 @@ "unknown": "Άγνωστο" } }, - "welcome": "Καλώς ήρθατε στον ιστότοπο της εφαρμογής Resgrid Unit" + "welcome": "Καλώς ήρθατε στον ιστότοπο της εφαρμογής Resgrid Unit", + "inventory": { + "title": "Απόθεμα", + "titleFor": "Απόθεμα · {{name}}", + "selectUnit": "Επιλέξτε πρώτα τη μονάδα σας.", + "unnamedLocation": "Χώρος", + "unnamedItem": "Είδος", + "countName": "Έλεγχος {{name}} {{date}}", + "notMigrated": "Το απόθεμα ρυθμίζεται για τον φορέα σας.", + "disabled": "Το απόθεμα είναι απενεργοποιημένο για τον φορέα σας.", + "noLocations": "Δεν έχει αντιστοιχιστεί απόθεμα σε αυτή τη μονάδα.", + "startCount": "Καταμέτρηση αποθέματος", + "wholeUnit": "Ολόκληρη η μονάδα: {{name}}", + "pickLocation": "Πού γίνεται η καταμέτρηση", + "startHelp": "Μετρήστε ό,τι υπάρχει πραγματικά στη μονάδα. Οι διαφορές καταχωρούνται με την ολοκλήρωση.", + "start": "Έναρξη καταμέτρησης", + "cannotCount": "Η καταμέτρηση απαιτεί το δικαίωμα προσαρμογής αποθέματος σε αυτή τη μονάδα.", + "counts": "Πρόσφατες καταμετρήσεις", + "count": "Καταμέτρηση αποθέματος", + "empty": "Δεν υπάρχει τίποτα εδώ.", + "serial": "Σειριακός {{serial}}", + "quantity": "× {{quantity}}", + "back": "Πίσω", + "completeTitle": "Ολοκλήρωση καταμέτρησης;", + "completeBody": "{{count}} είδη διαφέρουν από τα αναμενόμενα. Η ολοκλήρωση καταχωρεί τις διαφορές.", + "completeNoChanges": "Όλα ταιριάζουν με τα αναμενόμενα.", + "cancel": "Άκυρο", + "complete": "Ολοκλήρωση", + "cancelTitle": "Ακύρωση αυτής της καταμέτρησης;", + "cancelBody": "Τίποτα από όσα μετρήθηκαν δεν καταχωρείται.", + "keep": "Συνέχεια καταμέτρησης", + "cancelCount": "Ακύρωση καταμέτρησης", + "progress": "Μετρήθηκαν {{counted}} από {{total}}", + "differences": "{{count}} διαφορές", + "awaitingWitness": "Άλλαξε ελεγχόμενο είδος· ένα δεύτερο πρόσωπο πρέπει να βεβαιώσει την καταμέτρηση στον ιστό.", + "completed": "Η καταμέτρηση ολοκληρώθηκε.", + "expected": "Αναμενόμενο {{quantity}} {{unit}}", + "present": "Υπάρχει", + "missing": "Λείπει", + "less": "Ένα λιγότερο", + "more": "Ένα περισσότερο", + "matches": "Ταιριάζει", + "save": "Αποθήκευση προόδου", + "countStatus": { + "0": "Σε εξέλιξη", + "1": "Αναμένει μάρτυρα", + "2": "Ολοκληρώθηκε", + "3": "Ακυρώθηκε" + }, + "assetStatus": { + "0": "Σε υπηρεσία", + "1": "Διατέθηκε", + "2": "Σε επισκευή", + "3": "Κατεστραμμένο", + "4": "Χαμένο", + "5": "Αναλώθηκε", + "6": "Αποσύρθηκε" + }, + "errors": { + "retry": "Δεν ήταν δυνατή η σύνδεση με τον διακομιστή. Δοκιμάστε ξανά.", + "locked": "Ξεκλειδώστε τα προστατευμένα δεδομένα για να συνεχίσετε.", + "PermissionRequired": "Δεν έχετε δικαίωμα για αυτή την ενέργεια.", + "MembershipRequired": "Δεν είστε ενεργό μέλος αυτού του φορέα.", + "InventoryDisabled": "Το απόθεμα είναι απενεργοποιημένο για τον φορέα σας.", + "MigrationRequired": "Το απόθεμα ρυθμίζεται για τον φορέα σας.", + "CountScopeTooLarge": "Πάρα πολλά είδη για μία καταμέτρηση. Μετρήστε έναν χώρο τη φορά.", + "CountEmpty": "Δεν υπάρχει τίποτα για καταμέτρηση εδώ.", + "CountSnapshotChanged": "Το απόθεμα της μονάδας άλλαξε κατά την καταμέτρηση. Ακυρώστε και ξεκινήστε ξανά.", + "CountStateConflict": "Αυτή η καταμέτρηση άλλαξε σε άλλη συσκευή. Ανοίξτε την ξανά.", + "ReturnAssetFirst": "Ένα είδος που σημειώθηκε ως χαμένο είναι ακόμη διατεθειμένο σε κάποιον. Επιστρέψτε το πρώτα στον ιστό.", + "ProtectedDataRequired": "Ξεκλειδώστε τα προστατευμένα δεδομένα για να συνεχίσετε.", + "CountAlreadyExists": "Αυτή η καταμέτρηση υπάρχει ήδη.", + "LocationUnavailable": "Αυτή η τοποθεσία δεν είναι διαθέσιμη.", + "InvalidCount": "Ελέγξτε τις μετρημένες τιμές και δοκιμάστε ξανά." + } + } } diff --git a/src/translations/en.json b/src/translations/en.json index 870391ee..08e0797e 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -126,6 +126,12 @@ "title": "Call Notes" }, "call_detail": { + "activity_link": { + "auto": "Auto-linked", + "auto_hint": "Sent without a call; Resgrid linked it from the previous status or the dispatch.", + "inferred": "Inferred", + "inferred_hint": "Set with no destination by a unit or person dispatched to this call while working it." + }, "address": "Address", "call_location": "Call Location", "close_call": "Close Call", @@ -201,6 +207,7 @@ "dispatched": "Dispatched", "info": "Info", "protocols": "Protocols", + "site": "Site Info", "timeline": "Activity" }, "timestamp": "Timestamp", @@ -228,6 +235,9 @@ "contact_name_placeholder": "Enter the name of the contact", "contact_phone": "Contact Phone", "contact_phone_placeholder": "Enter the phone of the contact", + "contact_picker": { + "linked": "Linked contact: {{name}}" + }, "coordinates": "GPS Coordinates", "coordinates_found": "Coordinates found and address updated", "coordinates_geocoding_error": "Failed to get address for coordinates, but location set on map", @@ -445,6 +455,206 @@ "type_sector_rotation": "Sector Rotation", "type_unit": "Unit" }, + "checklists": { + "errors": { + "conflict": "This run changed on the server. Your local work is preserved; review before retrying.", + "denied": "Access is unavailable. Your encrypted local work is preserved.", + "locked": "Unlock protected data to continue.", + "offline": "Offline: showing securely saved checks.", + "retry": "Sync could not finish. Your local work is preserved.", + "storage_full": "The local draft or evidence limit was reached.", + "validation": "Check required answers, dates, notes and evidence, then retry." + }, + "labels": { + "Actions": "Actions", + "Active": "Active", + "AnchorMonth": "Starting month", + "Annual": "Annually", + "Answer": "Answer", + "Answered": "Answered", + "AssetUnavailable": "Serialized equipment is unavailable.", + "Assignment": "Assigned to", + "AssignmentAutomatic": "Automatic routing", + "AssignmentPermission": "You are not assigned to perform this check.", + "AssignmentType1": "Person", + "AssignmentType2": "Personnel role", + "AssignmentType3": "Group or station", + "AssignmentType4": "Unit crew", + "AssignmentUnavailable": "Choose an available assignment in this department.", + "Attest": "Attest and complete", + "Attestation": "Independent verification statement", + "Author": "Author", + "AwaitingWitness": "Awaiting witness", + "Back": "Back", + "CalendarInclude": "Readiness checks", + "CalendarRange": "Choose a calendar range of up to 93 days.", + "Cancelled": "Cancelled", + "Category": "Category", + "Checkbox": "Checkbox", + "Checked": "Checked", + "ChecklistCompleted": "Checklist completed", + "ChecklistFailed": "Checklist failed", + "ChecklistMissed": "Checklist missed", + "ChecklistOccurrenceSkipped": "Checklist occurrence excused", + "ChecklistScheduleChanged": "Checklist schedule changed", + "Checklists": "Checklists", + "Checks": "Checks", + "Choose": "Choose", + "Critical": "Critical", + "Daily": "Daily", + "Date": "Date", + "DayOfMonth": "Day of month", + "DeleteDraft": "Delete draft", + "Department": "Department", + "Draft": "Draft", + "DraftGuidance": "Save a draft, then publish it. Existing runs keep their original published version. Do not enter patient data.", + "DueChecks": "Due checks", + "DueGuidance": "Upcoming checks for seven days and unfinished checks. Missed checks can still be completed.", + "EditChecklist": "Checklist editor", + "EditDraft": "Edit draft", + "EditSchedule": "Edit schedule", + "Evidence": "Evidence", + "ExcuseCheck": "Excuse check", + "Export": "Export JSON", + "Facility": "Facility", + "Fail": "Fail", + "FailNote": "A failed check requires a note.", + "Failed": "Failed", + "Free": "Checklists are free for all departments.", + "Frequency": "Frequency", + "Guidance": "Adapt these starting points to your equipment, manufacturer procedures and local requirements before use.", + "History": "History", + "InProgress": "In progress", + "Incomplete": "Incomplete", + "InventoryAsset": "Serialized equipment", + "Item": "Item", + "Maintenance": "Maintenance and Work Orders (Readiness Pro)", + "Missed": "Missed", + "MonthGuidance": "For monthly or less frequent checks. Short months use their last day. The starting month anchors quarterly, six-monthly and annual checks.", + "Monthly": "Monthly", + "Name": "Name", + "NewChecklist": "New checklist", + "NewSchedule": "New schedule", + "Next": "Next", + "No": "No", + "NoDefinitions": "No checklists on this page.", + "NoDueChecks": "No checks in this list.", + "NoResults": "No templates match your search.", + "NoSchedules": "No schedules yet.", + "NoScore": "No applicable score", + "Note": "Note", + "OccurrenceUnavailable": "This check is unavailable or has already changed.", + "OpenCheck": "Open check", + "Optional": "Optional", + "Other": "Other", + "Pass": "Pass", + "Passed": "Passed", + "PerShift": "Each shift", + "Personnel": "Personnel", + "Photo": "Photo", + "Preview": "Preview template", + "Previous": "Previous", + "Print": "Print", + "PublishDraft": "Publish draft", + "PublishGuidance": "Publishing creates an immutable version for new runs.", + "Published": "Published", + "PublishedVersion": "Published version", + "Quantity": "Quantity", + "Quarterly": "Quarterly", + "ReminderBefore": "Minutes before the due time (0 disables)", + "ReminderDigest": "Combine pending reminders into digests", + "ReminderEnabled": "Enable checklist reminders", + "ReminderEscalate": "Escalate to department administrators after this many minutes overdue (blank disables)", + "ReminderFixedTime": "Daily digest time (department time zone; overrides shift starts)", + "ReminderHelp": "Reminders apply to checks starting after reminders are enabled. Messages contain a sign-in link and respect each person's notification preferences. Digests combine pending reminders for each recipient.", + "ReminderMessage": "Checklist updates are available. Sign in to review your checks.", + "ReminderMissed": "Notify responsible personnel of missed checks", + "ReminderSettings": "Checklist reminders", + "ReminderShiftStart": "Send a digest at shift start", + "ReminderTimeValidation": "Enter one time in HH:mm format.", + "ReminderTitle": "Checklist reminders", + "ReminderValidation": "Enter a reminder interval of 0–10080 minutes and an escalation interval of 1–43200 minutes, or leave escalation blank.", + "Remove": "Remove", + "ReportedLocation": "Reported location", + "Required": "Required", + "Result": "Result", + "Retire": "Retire", + "Retired": "Retired", + "RunGuidance": "Answer each applicable item. N/A requires a reason. Save progress to resume later. Submitted answers and evidence cannot be changed.", + "SaveDraft": "Save draft", + "SaveProgress": "Save progress", + "SaveReminders": "Save reminder settings", + "SaveSchedule": "Save schedule", + "ScheduleEnabled": "Schedule enabled", + "ScheduleEndDate": "End date (optional)", + "ScheduleNotes": "Schedule notes", + "SchedulePaused": "Paused", + "SchedulePermission": "You cannot manage checklist schedules.", + "ScheduleRequiresPublished": "Publish the checklist before scheduling it.", + "ScheduleStartDate": "Start date", + "ScheduleTimeGuidance": "Times use this zone. A skipped clock time moves forward; a repeated time occurs once.", + "ScheduleUnavailable": "This schedule is unavailable.", + "ScheduleValidation": "Check the schedule dates, times, target and recurrence settings.", + "ScheduleVersionGuidance": "Saving pins the current published version. Changes replace future unstarted checks; pausing cancels unstarted checks.", + "ScheduleWorkshift": "Workshift for checks each shift", + "Scheduled": "Scheduled", + "ScheduledFor": "Scheduled for", + "Schedules": "Schedules", + "Score": "Score", + "Search": "Search", + "SearchLabel": "Find a template", + "SelectTarget": "Select a target", + "SemiAnnual": "Every six months", + "Signature": "Signature", + "SkipReason": "Reason for excusing this check", + "SkipReasonRequired": "Enter a reason of up to 4,000 characters.", + "Skipped": "Excused", + "StartRun": "Start checklist", + "Started": "Started", + "State": "State", + "SubmitRun": "Submit checklist", + "Submitted": "Submitted", + "Target": "Target", + "Templates": "Checklist templates", + "TimeZone": "Time zone", + "TimesOfDay": "Local times", + "TimesOfDayGuidance": "Enter up to eight times, separated by commas, in 24-hour format.", + "Unanswered": "Unanswered", + "Unchecked": "Unchecked", + "Unit": "Unit", + "Units": "Units", + "Version": "Version", + "Weekly": "Weekly", + "WeeklyDays": "Days for weekly checks", + "WindowEnds": "Window ends", + "WindowMinutes": "Completion window (minutes)", + "Witness": "An authenticated author and a different authenticated member must independently verify the result.", + "WitnessInstructions": "This run needs a different authenticated member to independently verify the submitted answers and evidence. Share this page link with that member.", + "WitnessLabel": "Witness", + "WorkshiftFallback": "Use local times each day", + "WorkshiftGuidance": "Only for each-shift checks. A selected workshift uses its recorded start dates; otherwise local times apply daily.", + "Yes": "Yes" + }, + "ui": { + "calendar": "Calendar", + "clearSignature": "Clear signature", + "close": "Close", + "confirm": "Replace", + "editLocal": "Edit local draft", + "localDrafts": "Local drafts", + "notApplicable": "Not applicable", + "prepareOffline": "Prepare for offline", + "queued": "Saved securely on this device; waiting to sync.", + "reason": "Reason", + "refresh": "Refresh", + "removeImage": "Remove image", + "replaceLocal": "Replace your local answers and pending images with the server version?", + "retry": "Retry sync", + "takePhoto": "Take photo", + "unlock": "Unlock protected data", + "useServer": "Use server version" + } + }, "common": { "add": "Add", "back": "Back", @@ -536,6 +746,12 @@ "expires": "Expires", "facebook": "Facebook", "faxPhone": "Fax Phone", + "files": { + "download_failed": "The file could not be downloaded.", + "empty": "No files have been attached to this contact.", + "loading": "Loading files...", + "share_unavailable": "Sharing is not available on this device." + }, "formError": "Please fix the errors in the form", "homePhone": "Home Phone", "identification": "Identification", @@ -557,6 +773,48 @@ "otherInfo": "Other Information", "person": "Person", "phone": "Phone", + "preplan": { + "access": "Access", + "access_notes": "Access notes", + "alarm_company": "Alarm company", + "alarm_company_phone": "Alarm company phone", + "alarm_panel": "Alarm panel", + "alert_flag": "Alert", + "construction": "Construction · roof · occupancy", + "electric_shutoff": "Electric shutoff", + "emergency_contact": "Emergency contact", + "emergency_contact_phone": "Emergency contact phone", + "gas_shutoff": "Gas shutoff", + "gate_code": "Gate code", + "general_hazard_notes": "General hazard notes", + "general_hazards": "Hazards & tactics", + "hazards": "Premise hazards", + "hazmat_on_site": "Hazardous materials on site", + "knox_box": "Knox box", + "last_reviewed": "Last reviewed", + "loading": "Loading pre-plan...", + "location": "Location", + "nearest_hydrant": "Nearest hydrant", + "never_reviewed": "Never reviewed", + "next_review": "Next review", + "none": "No pre-incident plan has been recorded for this contact.", + "occupancy": "Construction & occupancy", + "occupancy_hours": "Hours of occupancy", + "occupancy_notes": "Occupancy notes", + "occupant_load": "Occupant load", + "occupants_needing_assistance": "Occupants may need evacuation assistance", + "on_site_contacts": "On-site contacts", + "required_fire_flow": "Required fire flow", + "review_overdue": "The pre-plan review date has passed.", + "secondary_contact": "Secondary contact", + "secondary_contact_phone": "Secondary contact phone", + "tactical_summary": "Tactical summary", + "utilities": "Utility shutoffs", + "utility_notes": "Utility notes", + "water_shutoff": "Water shutoff", + "water_supply": "Water supply", + "water_supply_notes": "Water supply notes" + }, "public": "Public", "required": "Required", "save": "Save Contact", @@ -569,7 +827,9 @@ "systemInformation": "System Information", "tabs": { "details": "Details", - "notes": "Notes" + "files": "Files", + "notes": "Notes", + "preplan": "Pre-Plan" }, "threads": "Threads", "title": "Contacts", @@ -578,7 +838,13 @@ "unknown_person": "Unknown Person", "visibility": "Visibility", "website": "Website", - "zip": "Zip Code" + "zip": "Zip Code", + "withheld": "{{count}} protected details are hidden.", + "categoryLabel": "Category: {{name}}", + "physicalAddress": "Physical address", + "mailingAddress": "Mailing address", + "hasGeofence": "This location has a mapped geofence.", + "customFields": "Custom fields" }, "data_protection": { "conceal": "Hide again", @@ -813,11 +1079,193 @@ "select_all": "Select All", "selected_count": "{{count}} selected", "title": "Notifications", - "unable_to_load": "Unable to load notifications" + "unable_to_load": "Unable to load notifications", + "detail_title": "Notification", + "additional_info": "Additional Information", + "view_call": "View Call", + "view_chat": "View Chat", + "open_reference": "Open" }, "onboarding": { "message": "Welcome to Resgrid Unit app site" }, + "operations": { + "all": "All", + "back": "Back", + "disabled": "Deployments are not enabled for this department.", + "entryType": { + "0": "Deployment", + "1": "Standby", + "2": "Travel" + }, + "errors": { + "denied": "You do not have access to this deployment.", + "disabled": "This feature is turned off for your department.", + "locked": "Unlock protected data to continue.", + "retry": "Could not reach the server. Try again.", + "validation": "The server rejected the change.", + "timereports_subject_covered": "That crew or person already has time on another report for this day.", + "timereports_date_exists": "A report for this crew or person already exists for that day.", + "timereports_scope_invalid": "That crew or person is not active on this deployment.", + "timereports_locked": "This report can no longer be changed.", + "timereports_time_invalid": "Enter times as HH:mm.", + "deployments_closed": "This deployment is closed.", + "expenses_receipt_invalid": "The receipt could not be read.", + "deployments_attachment_too_large": "The receipt is too large." + }, + "financeMode": { + "0": "Operational", + "1": "Cost recovery", + "2": "Billable" + }, + "issues": { + "end_before_start": "End time must be after the start time.", + "missing_subject": "Pick who or what this entry is for.", + "missing_time": "Enter a start and end time.", + "overlap": "This entry overlaps another for the same person or unit.", + "subject_outside_report_scope": "This entry is for someone outside this report's crew or person.", + "subject_on_other_report": "This person or unit already has time on another report for the same day.", + "subject_not_on_roster": "This entry is for someone who is not on the roster.", + "entries_overlap": "Two entries overlap for the same person or unit.", + "no_entries": "Add at least one entry.", + "break_rule": "A long shift has no break recorded.", + "long_travel": "More travel than allowed in one day; it needs approval.", + "outside_report_date": "This entry falls outside the report date." + }, + "mars": { + "counts": "{{errors}} errors · {{warnings}} warnings", + "draft": "Draft F-42 from time reports", + "f42": "F-42", + "help": "The F-42 is drafted from submitted time reports and checked against the CAL OES portal rules. Submission stays a manual, audited step on the web.", + "none": "No MARS work items for this deployment yet.", + "notReady": "Not ready for the portal", + "ready": "Ready for the portal", + "record": "MARS {{id}} · {{status}}", + "title": "F-42", + "validate": "Validate" + }, + "noNumbers": "No incident or request numbers", + "none": "No deployments.", + "open": "Open", + "phase": { + "0": "Mobilization", + "1": "Standby", + "2": "Incident", + "3": "Return" + }, + "reportStatus": { + "0": "Draft", + "1": "Submitted", + "2": "Approved", + "3": "Billed", + "4": "Void" + }, + "roster": "{{units}} units · {{personnel}} personnel", + "status": { + "0": "Planned", + "1": "Standby", + "2": "Active", + "3": "Demobilizing", + "4": "Completed", + "5": "Cancelled" + }, + "time": { + "accommodation": "Agency supplied accommodation", + "addEntry": "Add entry", + "from": "From", + "hours": "{{hours}} h", + "meals": "Agency supplied meals", + "noEntries": "No entries yet.", + "noReport": "No time report for this day.", + "notes": "Notes", + "remove": "Remove", + "report": "Report #{{number}}", + "save": "Save entries", + "start": "Start time report", + "subject": "Who or what", + "submit": "Submit report", + "title": "Time", + "to": "To", + "total": "Total {{hours}} h", + "type": "Entry type", + "unpaidBreak": "Unpaid break (min)", + "crewReport": "Crew time report #{{number}} · {{name}}", + "individualReport": "Time report #{{number}} · {{name}}", + "noCrewReport": "No crew time report for {{name}} on this day.", + "coveredBy": "This time is already on report #{{number}} for this day.", + "startCrew": "Start crew time report", + "readOnly": "You can view this report but not change it.", + "nextDay": "Ends the next day", + "applyToCrew": "Apply to crew", + "approve": "Approve report", + "dayReports": "Reports this day", + "awaitingApproval": "Awaiting approval ({{count}})", + "notOnRoster": "You are not on this deployment's roster or crew." + }, + "title": "Deployments", + "usage": { + "add": "Add reading", + "endOdometer": "End odometer", + "engineHours": "Engine hours", + "fuel": "Fuel", + "needsReview": "Needs review", + "phase": "Phase", + "saved": "Reading saved.", + "startOdometer": "Start odometer", + "title": "Usage", + "unit": "Unit" + }, + "crewOf": "Crew of {{names}}", + "previousDay": "Previous day", + "nextDay": "Next day", + "scope": { + "deployment": "Whole deployment", + "mine": "My time", + "crew": "Crew: {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "Signatures", + "crewBossSigned": "Crew boss signed {{when}}", + "crewBossPending": "Not signed by the crew boss yet.", + "customerSigned": "Agency signer: {{name}}", + "customerPending": "No agency signature recorded.", + "asCrewBoss": "Sign as crew boss", + "customerName": "Agency signer name", + "recordCustomer": "Record agency signature" + }, + "expenses": { + "title": "Expenses", + "type": { + "0": "Meal per diem", + "1": "Lodging", + "2": "Private lodging", + "3": "Ferry", + "4": "Fuel", + "5": "Supply restock", + "6": "Other" + }, + "typeLabel": "Expense type", + "amount": "Amount ({{currency}})", + "description": "Description", + "city": "City", + "takePhoto": "Take photo", + "choosePhoto": "Choose photo", + "receiptAttached": "Receipt photo attached.", + "save": "Add expense", + "add": "New expense", + "noneToday": "No expenses for this day.", + "otherDays": "Other days", + "hasReceipt": "receipt", + "remove": "Remove", + "removeTitle": "Remove expense?", + "removeBody": "This removes the expense and its receipt from the deployment.", + "cancel": "Cancel", + "saved": "Expense saved.", + "amountInvalid": "Enter an amount such as 42.50.", + "photoDenied": "Allow camera or photo access to attach a receipt." + } + }, "protocols": { "details": { "close": "Close", @@ -846,6 +1294,172 @@ "unknown_type_warning": "This notification type is not recognized. Please contact support if you continue to see this message.", "view_call": "View Call" }, + "records": { + "add_row": "Add row", + "agencies": "Agencies", + "agreement": "Agreement", + "assigned": "Assigned to you", + "attachment_failed": "That attachment could not be sent.", + "attachment_file_missing": "The file is no longer on this device.", + "attachment_unreadable": "That file could not be read.", + "attachment_will_resume": "Paused. It picks up where it stopped.", + "attachments": "Attachments", + "attachments_hint": "Attachments are added after the Record is saved.", + "attestation": "I attest this Record is complete and accurate", + "back": "Back", + "busy": "A read is already running", + "camera_denied": "The camera is not available without permission.", + "cancel": "Cancel", + "captured_on": "Captured", + "choose": "Choose…", + "choose_file": "Choose a file", + "choose_photo": "Choose a photo", + "closed_out_on": "Closed out", + "closeout_notes": "Closeout notes", + "conflict_app_version": "Records is not enabled for this app version.", + "conflict_definition_retired": "This form was retired. Start it again from the current list.", + "conflict_etag": "Someone else changed this Record. Reload it and reapply your changes.", + "conflict_permission": "You no longer have access to this Record.", + "conflict_protected_data": "This form needs a live connection and your protected-data access.", + "conflict_scope": "Your access changed, so this Record was reloaded.", + "connector_disabled": "Disabled", + "connector_maintained": "Maintained by connector", + "connector_not_ready": "Not ready", + "connector_ready": "Ready", + "connectors": "External connectors", + "connectors_hint": "Each connector reads one ordering system under its terms, an encrypted credential and a request limit. Write authority is refused.", + "connectors_home_hint": "Ordering-system feeds and what needs reconciling", + "cost_code": "Cost code", + "deployment_status_closed_out": "Closed out", + "deployment_status_mobilized": "Mobilized", + "deployment_status_open": "Open", + "deployment_status_released": "Released", + "deployments": "Deployments", + "deployments_hint": "The ordering system stays authoritative. Fills are moved by a coordinator on the web; nothing here changes one.", + "deployments_home_hint": "External resource orders your department is filling", + "drafts": "Drafts", + "field_required": "This field is required.", + "fill_status_accepted": "Accepted", + "fill_status_assigned": "Assigned", + "fill_status_checked_in": "Checked in", + "fill_status_declined": "Declined", + "fill_status_demobilized": "Demobilized", + "fill_status_mobilized": "Mobilized", + "fill_status_released": "Released", + "fill_status_requested": "Requested", + "fill_status_returned": "Returned", + "fills": "Requests and fills", + "finalize": "Finalize", + "finalized": "Finalized.", + "fix_fields": "Some fields still need attention.", + "forbidden": "Department administrators only", + "granted": "Granted", + "hide_closed": "Hide closed-out deployments", + "include_closed": "Show closed-out deployments", + "last_error": "Last error", + "last_read": "Last read", + "last_success": "Last success", + "load_failed": "That Record could not be loaded.", + "loading": "Loading Records…", + "location_kept": "Location kept", + "location_removed": "Location removed", + "manage_on_web": "Connectors are created, changed and given their credentials on the web.", + "minutes": "{{count}} min", + "needed_on": "Needed", + "needs_attention": "Needs you", + "never_read": "Never read", + "new_record": "New Record", + "no_assignments": "Nothing is assigned to you.", + "no_attachments": "No attachments yet.", + "no_connectors": "No connectors", + "no_deployments": "No deployments", + "no_drafts": "You have no drafts.", + "no_fills": "No requests on this order", + "no_recent": "No recent Records.", + "no_reconciliation": "No disagreements between the source and your records", + "no_returned": "Nothing has been returned to you.", + "no_rows": "No rows yet.", + "no_runs": "No runs yet", + "not_found": "That Record is not available.", + "not_granted": "Not granted", + "not_sent_yet": "Waiting to send", + "nothing_to_create": "Nothing can be started from here right now.", + "offices": "Offices", + "offline_capable": "Works offline", + "online_only": "This form is filled in online only.", + "online_only_badge": "Online only", + "open_artifact": "Open source artifact", + "open_record": "Open record", + "pending": "Not sent", + "poll_interval": "Poll interval", + "preview": "Preview", + "purpose_acknowledge": "Acknowledge this Record", + "purpose_attach": "Add an attachment", + "purpose_complete": "Complete this Record", + "purpose_correct": "Correct this Record", + "purpose_review": "Review this Record", + "read_authority": "Read authority", + "read_feed_now": "Read feed now", + "reading_feed": "Reading…", + "reason_app_disabled": "Records is not switched on for this app in your department.", + "reason_app_version_too_old": "Update the app to use Records.", + "reason_module_disabled": "Records is not available for your department.", + "reason_not_member": "You are not an active member of this department.", + "reason_records_not_usable": "Your department has not activated Records yet.", + "recent": "Recent", + "reconciliation": "Reconciliation", + "reconciliation_count": "{{count}} to reconcile", + "reconciliation_hint": "Where the source and your record disagree. Nothing is applied automatically; a coordinator decides on the web.", + "reconciliation_local_fill_missing_in_source": "Fill exists locally but not in the source", + "reconciliation_source_closed_local_open": "Source closed the order, but fills are still open", + "reconciliation_source_released_local_not_returned": "Source released the order, but a fill is not yet returned", + "reconciliation_source_request_missing_locally": "Request exists in the source but not locally", + "reconciliation_source_status_ahead": "Source status is ahead of the local fill", + "reconciliation_source_status_behind": "Source status is behind the local fill", + "refresh_catalog": "Refresh", + "requests_this_hour": "Requests this hour", + "restricted": "Restricted", + "returned": "Returned to you", + "run_counts": "Seen {{seen}} · created {{created}} · snapshots {{snapshots}} · requests {{requests}} · unchanged {{unchanged}} · rejected {{rejected}} · to reconcile {{conflicts}}", + "run_failed": "The feed could not be read", + "run_finished": "Feed read {{outcome}}: {{created}} created, {{snapshots}} snapshots, {{requests}} requests, {{conflicts}} to reconcile", + "run_log": "Run log", + "run_not_ok": "Feed read ended with {{outcome}}", + "run_outcome_disabled": "Disabled", + "run_outcome_failed": "Failed", + "run_outcome_ok": "OK", + "run_outcome_rate_limited": "Rate limited", + "run_outcome_rejected": "Rejected", + "run_trigger_inbound": "Pushed by source", + "run_trigger_manual": "Read now", + "run_trigger_poll": "Scheduled", + "save": "Save", + "save_and_send": "Save and send", + "save_failed": "That could not be saved.", + "save_locally": "Keep on this device", + "saved": "Saved.", + "saved_locally": "Kept on this device. It sends when you are back online.", + "sign_statement": "I sign this Record as its author", + "source": "Source", + "source_says": "Source:", + "submit_for_review": "Submit for review", + "submitted": "Sent for review.", + "take_photo": "Take a photo", + "terms": "Source terms", + "terms_acknowledged": "Acknowledged", + "terms_not_acknowledged": "Not yet acknowledged", + "title": "Records", + "trainee": "Trainee", + "unavailable": "Records is not available in this app right now.", + "unit_code": "Unit", + "unsupported_definition": "This form uses a field this app version cannot fill in. Update the app, or use the web app.", + "unsupported_field": "This app cannot show this field yet. Use the web app to fill it in.", + "untitled": "Untitled", + "update_required": "App update needed", + "write_authority": "Write authority", + "write_refused": "Refused — nothing is written back", + "your_record_says": "Your record:" + }, "roles": { "assignedElsewhere": "In another role", "clearAssignment": "Clear the current assignment", @@ -1117,6 +1731,17 @@ "version": "Version", "website": "Website" }, + "site_info": { + "additional_contact": "Additional contact", + "alert_notes": "Alert notes", + "empty": "No site information", + "empty_description": "No contacts with a pre-plan, hazards, alert notes or files are linked to this call.", + "entrance": "Entrance", + "files": "Site files", + "load_failed": "Site information could not be loaded.", + "loading": "Loading site information...", + "primary_contact": "Primary contact" + }, "status": { "add_note": "Add Note", "both_destinations_enabled": "Can respond to calls or stations", @@ -1157,6 +1782,7 @@ "map": "Map", "notes": "Notes", "protocols": "Protocols", + "records": "Records", "routes": "Routes", "settings": "Settings", "weather_alerts": "Weather" @@ -1277,5 +1903,80 @@ "unknown": "Unknown" } }, - "welcome": "Welcome to Resgrid Unit app site" + "welcome": "Welcome to Resgrid Unit app site", + "inventory": { + "title": "Inventory", + "titleFor": "Inventory · {{name}}", + "selectUnit": "Select your unit first.", + "unnamedLocation": "Compartment", + "unnamedItem": "Item", + "countName": "{{name}} check {{date}}", + "notMigrated": "Inventory is being set up for your department.", + "disabled": "Inventory is turned off for your department.", + "noLocations": "No inventory is assigned to this unit.", + "startCount": "Count the inventory", + "wholeUnit": "Whole unit: {{name}}", + "pickLocation": "Where to count", + "startHelp": "Count what is actually on the unit. Differences are posted when you complete the count.", + "start": "Start count", + "cannotCount": "Counting needs the Adjust Inventory permission at this unit.", + "counts": "Recent counts", + "count": "Inventory count", + "empty": "Nothing here.", + "serial": "Serial {{serial}}", + "quantity": "× {{quantity}}", + "back": "Back", + "completeTitle": "Complete count?", + "completeBody": "{{count}} items differ from what was expected. Completing posts the differences.", + "completeNoChanges": "Everything matches what was expected.", + "cancel": "Cancel", + "complete": "Complete", + "cancelTitle": "Cancel this count?", + "cancelBody": "Nothing counted so far is posted.", + "keep": "Keep counting", + "cancelCount": "Cancel count", + "progress": "{{counted}} of {{total}} counted", + "differences": "{{count}} differences", + "awaitingWitness": "A controlled item changed; a second person must witness this count on the web.", + "completed": "Count completed.", + "expected": "Expected {{quantity}} {{unit}}", + "present": "Present", + "missing": "Missing", + "less": "One less", + "more": "One more", + "matches": "Matches", + "save": "Save progress", + "countStatus": { + "0": "In progress", + "1": "Awaiting witness", + "2": "Completed", + "3": "Cancelled" + }, + "assetStatus": { + "0": "In service", + "1": "Issued", + "2": "Out for repair", + "3": "Damaged", + "4": "Lost", + "5": "Consumed", + "6": "Retired" + }, + "errors": { + "retry": "Could not reach the server. Try again.", + "locked": "Unlock protected data to continue.", + "PermissionRequired": "You do not have permission to do this.", + "MembershipRequired": "You are not an active member of this department.", + "InventoryDisabled": "Inventory is turned off for your department.", + "MigrationRequired": "Inventory is being set up for your department.", + "CountScopeTooLarge": "Too many items for one count. Count one compartment at a time.", + "CountEmpty": "There is nothing to count here.", + "CountSnapshotChanged": "Inventory at this unit changed while you counted. Cancel this count and start again.", + "CountStateConflict": "This count changed on another device. Reopen it and try again.", + "ReturnAssetFirst": "An item marked missing is still issued to someone. Return it first on the web.", + "ProtectedDataRequired": "Unlock protected data to continue.", + "CountAlreadyExists": "This count already exists.", + "LocationUnavailable": "That location is not available.", + "InvalidCount": "Check the counted values and try again." + } + } } diff --git a/src/translations/es.json b/src/translations/es.json index d3b6e145..f2119f14 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -126,6 +126,12 @@ "title": "Notas de la llamada" }, "call_detail": { + "activity_link": { + "auto": "Vinculado automáticamente", + "auto_hint": "Enviado sin llamada; Resgrid lo vinculó a partir del estado anterior o del despacho.", + "inferred": "Inferido", + "inferred_hint": "Establecido sin destino por una unidad o persona despachada a esta llamada mientras la atendía." + }, "address": "Dirección", "call_location": "Ubicación de la llamada", "close_call": "Cerrar llamada", @@ -201,6 +207,7 @@ "dispatched": "Despachadas", "info": "Información", "protocols": "Protocolos", + "site": "Info. del sitio", "timeline": "Actividad" }, "timestamp": "Marca de tiempo", @@ -228,6 +235,9 @@ "contact_name_placeholder": "Introduce el nombre del contacto", "contact_phone": "Teléfono del contacto", "contact_phone_placeholder": "Introduce el teléfono del contacto", + "contact_picker": { + "linked": "Contacto vinculado: {{name}}" + }, "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", @@ -445,6 +455,206 @@ "type_sector_rotation": "Rotación de sector", "type_unit": "Unidad" }, + "checklists": { + "errors": { + "conflict": "Esta revisión cambió en el servidor. Se conserva tu trabajo local; revísalo antes de reintentar.", + "denied": "El acceso no está disponible. Se conserva tu trabajo local cifrado.", + "locked": "Desbloquea los datos protegidos para continuar.", + "offline": "Sin conexión: se muestran revisiones guardadas de forma segura.", + "retry": "No se pudo completar la sincronización. Se conserva tu trabajo local.", + "storage_full": "Se alcanzó el límite local de borradores o pruebas.", + "validation": "Revisa las respuestas obligatorias, fechas, notas y pruebas, y vuelve a intentarlo." + }, + "labels": { + "Actions": "Acciones", + "Active": "Activa", + "AnchorMonth": "Mes de referencia", + "Annual": "Anualmente", + "Answer": "Respuesta", + "Answered": "Respondido", + "AssetUnavailable": "El equipo con número de serie no está disponible.", + "Assignment": "Asignado a", + "AssignmentAutomatic": "Asignación automática", + "AssignmentPermission": "No tiene asignada la realización de esta comprobación.", + "AssignmentType1": "Persona", + "AssignmentType2": "Función del personal", + "AssignmentType3": "Grupo o estación", + "AssignmentType4": "Dotación de la unidad", + "AssignmentUnavailable": "Seleccione una asignación disponible en esta organización.", + "Attest": "Confirmar y completar", + "Attestation": "Declaración de verificación independiente", + "Author": "Autor", + "AwaitingWitness": "Pendiente de testigo", + "Back": "Volver", + "CalendarInclude": "Comprobaciones de disponibilidad", + "CalendarRange": "Seleccione un intervalo de calendario de hasta 93 días.", + "Cancelled": "Cancelada", + "Category": "Categoría", + "Checkbox": "Casilla de verificación", + "Checked": "Marcada", + "ChecklistCompleted": "Lista de comprobación completada", + "ChecklistFailed": "Lista de comprobación no superada", + "ChecklistMissed": "Lista de comprobación no realizada", + "ChecklistOccurrenceSkipped": "Omisión de revisión programada justificada", + "ChecklistScheduleChanged": "Programación de revisión modificada", + "Checklists": "Listas de verificación", + "Checks": "Verificaciones", + "Choose": "Seleccione", + "Critical": "Crítico", + "Daily": "Diariamente", + "Date": "Fecha", + "DayOfMonth": "Día del mes", + "DeleteDraft": "Eliminar borrador", + "Department": "Departamento", + "Draft": "Borrador", + "DraftGuidance": "Guarde un borrador y publíquelo. Las ejecuciones existentes conservan su versión original. No introduzca datos de pacientes.", + "DueChecks": "Revisiones pendientes", + "DueGuidance": "Próximas revisiones de siete días y revisiones sin terminar. Las revisiones no realizadas aún se pueden completar.", + "EditChecklist": "Editor de listas", + "EditDraft": "Editar borrador", + "EditSchedule": "Editar programación", + "Evidence": "Prueba", + "ExcuseCheck": "Justificar omisión", + "Export": "Exportar JSON", + "Facility": "Instalaciones", + "Fail": "No aprobado", + "FailNote": "Una comprobación fallida requiere una nota.", + "Failed": "No aprobado", + "Free": "Las listas son gratuitas para todos los departamentos.", + "Frequency": "Frecuencia", + "Guidance": "Adapte las plantillas a su equipo, los procedimientos del fabricante y los requisitos locales antes de usarlas.", + "History": "Historial", + "InProgress": "En curso", + "Incomplete": "Incompleto", + "InventoryAsset": "Equipo con número de serie", + "Item": "Elemento", + "Maintenance": "Mantenimiento y órdenes de trabajo (Readiness Pro)", + "Missed": "No realizada", + "MonthGuidance": "Para revisiones mensuales o menos frecuentes. Los meses cortos usan su último día. El mes de referencia determina las revisiones trimestrales, semestrales y anuales.", + "Monthly": "Mensualmente", + "Name": "Nombre", + "NewChecklist": "Nueva lista", + "NewSchedule": "Nueva programación", + "Next": "Siguiente", + "No": "No", + "NoDefinitions": "No hay listas en esta página.", + "NoDueChecks": "No hay revisiones en esta lista.", + "NoResults": "No hay plantillas que coincidan.", + "NoSchedules": "Aún no hay programaciones.", + "NoScore": "Sin puntuación aplicable", + "Note": "Nota", + "OccurrenceUnavailable": "Esta revisión no está disponible o ya ha cambiado.", + "OpenCheck": "Abrir revisión", + "Optional": "Opcional", + "Other": "Otra", + "Pass": "Aprobado", + "Passed": "Aprobado", + "PerShift": "Cada turno", + "Personnel": "Personal", + "Photo": "Foto", + "Preview": "Vista previa", + "Previous": "Anterior", + "Print": "Imprimir", + "PublishDraft": "Publicar borrador", + "PublishGuidance": "La publicación crea una versión inmutable para nuevas ejecuciones.", + "Published": "Publicada", + "PublishedVersion": "Versión publicada", + "Quantity": "Cantidad", + "Quarterly": "Trimestralmente", + "ReminderBefore": "Minutos antes del vencimiento (0 desactiva)", + "ReminderDigest": "Agrupar los recordatorios pendientes en resúmenes", + "ReminderEnabled": "Activar recordatorios de listas de verificación", + "ReminderEscalate": "Avisar a los administradores del departamento tras estos minutos de retraso (vacío desactiva)", + "ReminderFixedTime": "Hora del resumen diario (zona horaria de la organización; sustituye al inicio del turno)", + "ReminderHelp": "Los recordatorios se aplican a las verificaciones que comiencen después de activarlos. Los mensajes incluyen un enlace para iniciar sesión y respetan las preferencias de notificación de cada persona. Los resúmenes agrupan los recordatorios pendientes por destinatario.", + "ReminderMessage": "Hay actualizaciones de listas de verificación. Inicie sesión para revisar sus verificaciones.", + "ReminderMissed": "Avisar al personal responsable de las verificaciones omitidas", + "ReminderSettings": "Recordatorios de listas de verificación", + "ReminderShiftStart": "Enviar un resumen al inicio del turno", + "ReminderTimeValidation": "Introduzca una sola hora con el formato HH:mm.", + "ReminderTitle": "Recordatorios de listas de verificación", + "ReminderValidation": "Indique un intervalo de 0–10080 minutos para los recordatorios y de 1–43200 minutos para el aviso a administradores, o deje este último vacío.", + "Remove": "Eliminar", + "ReportedLocation": "Ubicación indicada", + "Required": "Obligatorio", + "Result": "Resultado", + "Retire": "Retirar", + "Retired": "Retirada", + "RunGuidance": "Responda cada elemento aplicable. No aplicable requiere un motivo. Guarde el progreso para continuar después. Las respuestas y pruebas enviadas no se pueden cambiar.", + "SaveDraft": "Guardar borrador", + "SaveProgress": "Guardar progreso", + "SaveReminders": "Guardar configuración de recordatorios", + "SaveSchedule": "Guardar programación", + "ScheduleEnabled": "Programación habilitada", + "ScheduleEndDate": "Fecha de fin (opcional)", + "ScheduleNotes": "Notas de la programación", + "SchedulePaused": "En pausa", + "SchedulePermission": "No tiene permiso para gestionar programaciones de revisión.", + "ScheduleRequiresPublished": "Publique la lista de comprobación antes de programarla.", + "ScheduleStartDate": "Fecha de inicio", + "ScheduleTimeGuidance": "Las horas usan esta zona. Una hora inexistente se adelanta; una hora repetida se ejecuta una sola vez.", + "ScheduleUnavailable": "Esta programación no está disponible.", + "ScheduleValidation": "Revise las fechas, horas, objetivo y repetición de la programación.", + "ScheduleVersionGuidance": "Al guardar se fija la versión publicada actual. Los cambios sustituyen revisiones futuras no iniciadas; la pausa cancela las no iniciadas.", + "ScheduleWorkshift": "Turno para revisiones por turno", + "Scheduled": "Programada", + "ScheduledFor": "Programada para", + "Schedules": "Programaciones", + "Score": "Puntuación", + "Search": "Buscar", + "SearchLabel": "Buscar plantilla", + "SelectTarget": "Seleccione un destino", + "SemiAnnual": "Cada seis meses", + "Signature": "Firma", + "SkipReason": "Motivo de la omisión justificada", + "SkipReasonRequired": "Introduzca un motivo de hasta 4.000 caracteres.", + "Skipped": "Omisión justificada", + "StartRun": "Iniciar lista", + "Started": "Inicio", + "State": "Estado", + "SubmitRun": "Enviar lista", + "Submitted": "Enviada", + "Target": "Destino", + "Templates": "Plantillas de listas", + "TimeZone": "Zona horaria", + "TimesOfDay": "Horas locales", + "TimesOfDayGuidance": "Introduzca hasta ocho horas en formato de 24 horas, separadas por comas.", + "Unanswered": "Sin respuesta", + "Unchecked": "Desmarcada", + "Unit": "Unidad", + "Units": "Unidades", + "Version": "Versión", + "Weekly": "Semanalmente", + "WeeklyDays": "Días de las revisiones semanales", + "WindowEnds": "Fin del plazo", + "WindowMinutes": "Plazo de realización (minutos)", + "Witness": "El autor autenticado y otro miembro autenticado deben verificar el resultado de forma independiente.", + "WitnessInstructions": "Otro miembro autenticado debe verificar de forma independiente las respuestas y pruebas enviadas. Comparta el enlace de esta página con esa persona.", + "WitnessLabel": "Testigo", + "WorkshiftFallback": "Usar horas locales cada día", + "WorkshiftGuidance": "Solo para revisiones por turno. Un turno seleccionado usa sus fechas de inicio registradas; de lo contrario, se usan las horas locales cada día.", + "Yes": "Sí" + }, + "ui": { + "calendar": "Calendario", + "clearSignature": "Borrar firma", + "close": "Cerrar", + "confirm": "Reemplazar", + "editLocal": "Editar borrador local", + "localDrafts": "Borradores locales", + "notApplicable": "No corresponde", + "prepareOffline": "Preparar para uso sin conexión", + "queued": "Guardado de forma segura en este dispositivo; pendiente de sincronizar.", + "reason": "Motivo", + "refresh": "Actualizar", + "removeImage": "Eliminar imagen", + "replaceLocal": "¿Reemplazar las respuestas locales y las imágenes pendientes por la versión del servidor?", + "retry": "Reintentar sincronización", + "takePhoto": "Tomar foto", + "unlock": "Desbloquear datos protegidos", + "useServer": "Usar versión del servidor" + } + }, "common": { "add": "Añadir", "back": "Atrás", @@ -536,6 +746,12 @@ "expires": "Expira", "facebook": "Facebook", "faxPhone": "Teléfono de fax", + "files": { + "download_failed": "No se pudo descargar el archivo.", + "empty": "No se han adjuntado archivos a este contacto.", + "loading": "Cargando archivos...", + "share_unavailable": "Compartir no está disponible en este dispositivo." + }, "formError": "Por favor, corrige los errores en el formulario", "homePhone": "Teléfono de casa", "identification": "Identificación", @@ -557,6 +773,48 @@ "otherInfo": "Otra información", "person": "Persona", "phone": "Teléfono", + "preplan": { + "access": "Acceso", + "access_notes": "Notas de acceso", + "alarm_company": "Empresa de alarmas", + "alarm_company_phone": "Teléfono de la empresa de alarmas", + "alarm_panel": "Panel de alarma", + "alert_flag": "Alerta", + "construction": "Construcción · techo · ocupación", + "electric_shutoff": "Corte eléctrico", + "emergency_contact": "Contacto de emergencia", + "emergency_contact_phone": "Teléfono del contacto de emergencia", + "gas_shutoff": "Corte de gas", + "gate_code": "Código del portón", + "general_hazard_notes": "Notas generales de peligros", + "general_hazards": "Peligros y tácticas", + "hazards": "Peligros del inmueble", + "hazmat_on_site": "Materiales peligrosos en el sitio", + "knox_box": "Caja Knox", + "last_reviewed": "Última revisión", + "loading": "Cargando preplan...", + "location": "Ubicación", + "nearest_hydrant": "Hidrante más cercano", + "never_reviewed": "Nunca revisado", + "next_review": "Próxima revisión", + "none": "No se ha registrado un plan previo al incidente para este contacto.", + "occupancy": "Construcción y ocupación", + "occupancy_hours": "Horario de ocupación", + "occupancy_notes": "Notas de ocupación", + "occupant_load": "Carga de ocupantes", + "occupants_needing_assistance": "Los ocupantes pueden necesitar ayuda para evacuar", + "on_site_contacts": "Contactos en el sitio", + "required_fire_flow": "Caudal requerido", + "review_overdue": "La fecha de revisión del preplan ha vencido.", + "secondary_contact": "Contacto secundario", + "secondary_contact_phone": "Teléfono del contacto secundario", + "tactical_summary": "Resumen táctico", + "utilities": "Cortes de servicios", + "utility_notes": "Notas de servicios", + "water_shutoff": "Corte de agua", + "water_supply": "Suministro de agua", + "water_supply_notes": "Notas de suministro de agua" + }, "public": "Público", "required": "Requerido", "save": "Guardar contacto", @@ -569,7 +827,9 @@ "systemInformation": "Información del sistema", "tabs": { "details": "Detalles", - "notes": "Notas" + "files": "Archivos", + "notes": "Notas", + "preplan": "Preplan" }, "threads": "Threads", "title": "Contactos", @@ -578,7 +838,13 @@ "unknown_person": "Persona desconocida", "visibility": "Visibilidad", "website": "Sitio web", - "zip": "Código postal" + "zip": "Código postal", + "withheld": "Hay {{count}} datos protegidos ocultos.", + "categoryLabel": "Categoría: {{name}}", + "physicalAddress": "Dirección física", + "mailingAddress": "Dirección postal", + "hasGeofence": "Esta ubicación tiene una geocerca en el mapa.", + "customFields": "Campos personalizados" }, "data_protection": { "conceal": "Ocultar de nuevo", @@ -813,11 +1079,193 @@ "select_all": "Seleccionar todo", "selected_count": "{{count}} seleccionadas", "title": "Notificaciones", - "unable_to_load": "No se pudieron cargar las notificaciones" + "unable_to_load": "No se pudieron cargar las notificaciones", + "detail_title": "Notificación", + "additional_info": "Información adicional", + "view_call": "Ver Llamada", + "view_chat": "Ver chat", + "open_reference": "Abrir" }, "onboarding": { "message": "Bienvenido al sitio de la aplicación Resgrid Unit" }, + "operations": { + "all": "Todos", + "back": "Atrás", + "disabled": "Los despliegues no están habilitados para este departamento.", + "entryType": { + "0": "Despliegue", + "1": "En espera", + "2": "Traslado" + }, + "errors": { + "denied": "No tiene acceso a este despliegue.", + "disabled": "Esta función está desactivada para su departamento.", + "locked": "Desbloquee los datos protegidos para continuar.", + "retry": "No se pudo conectar con el servidor. Inténtelo de nuevo.", + "validation": "El servidor rechazó el cambio.", + "timereports_subject_covered": "Esa tripulación o persona ya tiene horas en otro parte de este día.", + "timereports_date_exists": "Ya existe un parte para esa tripulación o persona ese día.", + "timereports_scope_invalid": "Esa tripulación o persona no está activa en este despliegue.", + "timereports_locked": "Este parte ya no se puede modificar.", + "timereports_time_invalid": "Introduzca las horas como HH:mm.", + "deployments_closed": "Este despliegue está cerrado.", + "expenses_receipt_invalid": "No se pudo leer el recibo.", + "deployments_attachment_too_large": "El recibo es demasiado grande." + }, + "financeMode": { + "0": "Operativo", + "1": "Recuperación de costos", + "2": "Facturable" + }, + "issues": { + "end_before_start": "La hora de fin debe ser posterior a la de inicio.", + "missing_subject": "Elija a quién o a qué corresponde esta entrada.", + "missing_time": "Introduzca una hora de inicio y de fin.", + "overlap": "Esta entrada se solapa con otra de la misma persona o unidad.", + "subject_outside_report_scope": "Esta entrada es de alguien ajeno a la tripulación o persona de este parte.", + "subject_on_other_report": "Esta persona o unidad ya tiene horas en otro parte del mismo día.", + "subject_not_on_roster": "Esta entrada es de alguien que no figura en la dotación.", + "entries_overlap": "Dos entradas de la misma persona o unidad se solapan.", + "no_entries": "Añada al menos una entrada.", + "break_rule": "Un turno largo no tiene descanso registrado.", + "long_travel": "Más viaje del permitido en un día; necesita aprobación.", + "outside_report_date": "Esta entrada queda fuera de la fecha del parte." + }, + "mars": { + "counts": "{{errors}} errores · {{warnings}} avisos", + "draft": "Generar borrador F-42 desde los partes de tiempo", + "f42": "F-42", + "help": "El F-42 se genera a partir de los partes de tiempo enviados y se comprueba con las reglas del portal CAL OES. El envío sigue siendo un paso manual y auditado en la web.", + "none": "Todavía no hay elementos MARS para este despliegue.", + "notReady": "No está listo para el portal", + "ready": "Listo para el portal", + "record": "MARS {{id}} · {{status}}", + "title": "F-42", + "validate": "Validar" + }, + "noNumbers": "Sin números de incidente o solicitud", + "none": "No hay despliegues.", + "open": "Abiertos", + "phase": { + "0": "Movilización", + "1": "En espera", + "2": "Incidente", + "3": "Regreso" + }, + "reportStatus": { + "0": "Borrador", + "1": "Enviado", + "2": "Aprobado", + "3": "Facturado", + "4": "Anulado" + }, + "roster": "{{units}} unidades · {{personnel}} personas", + "status": { + "0": "Planificado", + "1": "En espera", + "2": "Activo", + "3": "Desmovilizando", + "4": "Completado", + "5": "Cancelado" + }, + "time": { + "accommodation": "Alojamiento provisto por la agencia", + "addEntry": "Añadir entrada", + "from": "De", + "hours": "{{hours}} h", + "meals": "Comidas provistas por la agencia", + "noEntries": "Todavía no hay entradas.", + "noReport": "No hay parte de tiempo para este día.", + "notes": "Notas", + "remove": "Quitar", + "report": "Parte #{{number}}", + "save": "Guardar entradas", + "start": "Iniciar parte de tiempo", + "subject": "Quién o qué", + "submit": "Enviar parte", + "title": "Tiempo", + "to": "A", + "total": "Total {{hours}} h", + "type": "Tipo de entrada", + "unpaidBreak": "Descanso no pagado (min)", + "crewReport": "Parte de horas de tripulación #{{number}} · {{name}}", + "individualReport": "Parte de horas #{{number}} · {{name}}", + "noCrewReport": "No hay parte de tripulación para {{name}} este día.", + "coveredBy": "Estas horas ya están en el parte #{{number}} de este día.", + "startCrew": "Iniciar parte de tripulación", + "readOnly": "Puede ver este parte pero no modificarlo.", + "nextDay": "Termina al día siguiente", + "applyToCrew": "Aplicar a la tripulación", + "approve": "Aprobar parte", + "dayReports": "Partes de este día", + "awaitingApproval": "Pendientes de aprobación ({{count}})", + "notOnRoster": "No figura en la dotación ni en una tripulación de este despliegue." + }, + "title": "Despliegues", + "usage": { + "add": "Añadir lectura", + "endOdometer": "Odómetro final", + "engineHours": "Horas de motor", + "fuel": "Combustible", + "needsReview": "Requiere revisión", + "phase": "Fase", + "saved": "Lectura guardada.", + "startOdometer": "Odómetro inicial", + "title": "Uso", + "unit": "Unidad" + }, + "crewOf": "Tripulación de {{names}}", + "previousDay": "Día anterior", + "nextDay": "Día siguiente", + "scope": { + "deployment": "Todo el despliegue", + "mine": "Mis horas", + "crew": "Tripulación: {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "Firmas", + "crewBossSigned": "Firmado por el jefe de tripulación {{when}}", + "crewBossPending": "Aún sin firma del jefe de tripulación.", + "customerSigned": "Firmante del organismo: {{name}}", + "customerPending": "No hay firma del organismo registrada.", + "asCrewBoss": "Firmar como jefe de tripulación", + "customerName": "Nombre del firmante del organismo", + "recordCustomer": "Registrar firma del organismo" + }, + "expenses": { + "title": "Gastos", + "type": { + "0": "Dieta de comidas", + "1": "Alojamiento", + "2": "Alojamiento privado", + "3": "Ferry", + "4": "Combustible", + "5": "Reposición de material", + "6": "Otro" + }, + "typeLabel": "Tipo de gasto", + "amount": "Importe ({{currency}})", + "description": "Descripción", + "city": "Ciudad", + "takePhoto": "Hacer foto", + "choosePhoto": "Elegir foto", + "receiptAttached": "Foto del recibo adjunta.", + "save": "Añadir gasto", + "add": "Nuevo gasto", + "noneToday": "No hay gastos este día.", + "otherDays": "Otros días", + "hasReceipt": "recibo", + "remove": "Quitar", + "removeTitle": "¿Quitar el gasto?", + "removeBody": "Esto quita el gasto y su recibo del despliegue.", + "cancel": "Cancelar", + "saved": "Gasto guardado.", + "amountInvalid": "Introduzca un importe como 42.50.", + "photoDenied": "Permita el acceso a la cámara o a las fotos para adjuntar un recibo." + } + }, "protocols": { "details": { "close": "Cerrar", @@ -846,6 +1294,172 @@ "unknown_type_warning": "Este tipo de notificación no se reconoce. Por favor, contacte con soporte si continúa viendo este mensaje.", "view_call": "Ver Llamada" }, + "records": { + "add_row": "Añadir fila", + "agencies": "Agencias", + "agreement": "Acuerdo", + "assigned": "Asignado a usted", + "attachment_failed": "No se pudo enviar ese adjunto.", + "attachment_file_missing": "El archivo ya no está en este dispositivo.", + "attachment_unreadable": "No se pudo leer ese archivo.", + "attachment_will_resume": "En pausa. Continuará donde se detuvo.", + "attachments": "Adjuntos", + "attachments_hint": "Los adjuntos se añaden después de guardar el registro.", + "attestation": "Certifico que este registro está completo y es exacto", + "back": "Atrás", + "busy": "Ya hay una lectura en curso", + "camera_denied": "La cámara no está disponible sin permiso.", + "cancel": "Cancelar", + "captured_on": "Capturado", + "choose": "Elegir…", + "choose_file": "Elegir un archivo", + "choose_photo": "Elegir una foto", + "closed_out_on": "Cerrado el", + "closeout_notes": "Notas de cierre", + "conflict_app_version": "Los registros no están habilitados para esta versión de la aplicación.", + "conflict_definition_retired": "Este formulario fue retirado. Iníciela de nuevo desde la lista actual.", + "conflict_etag": "Otra persona cambió este registro. Vuelva a cargarlo y aplique sus cambios de nuevo.", + "conflict_permission": "Ya no tiene acceso a este registro.", + "conflict_protected_data": "Este formulario necesita conexión activa y su acceso a datos protegidos.", + "conflict_scope": "Su acceso cambió, por lo que este registro se volvió a cargar.", + "connector_disabled": "Desactivado", + "connector_maintained": "Mantenido por un conector", + "connector_not_ready": "No listo", + "connector_ready": "Listo", + "connectors": "Conectores externos", + "connectors_hint": "Cada conector lee un sistema de pedidos bajo sus condiciones, con una credencial cifrada y un límite de solicitudes. La autoridad de escritura se rechaza.", + "connectors_home_hint": "Feeds de sistemas de pedidos y lo que hay que conciliar", + "cost_code": "Código de costo", + "deployment_status_closed_out": "Cerrado", + "deployment_status_mobilized": "Movilizado", + "deployment_status_open": "Abierta", + "deployment_status_released": "Liberado", + "deployments": "Despliegues", + "deployments_hint": "El sistema de pedidos sigue siendo la autoridad. Un coordinador mueve las asignaciones en la web; aquí no se cambia ninguna.", + "deployments_home_hint": "Órdenes de recursos externas que su departamento cubre", + "drafts": "Borradores", + "field_required": "Este campo es obligatorio.", + "fill_status_accepted": "Aceptado", + "fill_status_assigned": "Asignado", + "fill_status_checked_in": "Registrado", + "fill_status_declined": "Rechazado", + "fill_status_demobilized": "Desmovilizado", + "fill_status_mobilized": "Movilizado", + "fill_status_released": "Liberado", + "fill_status_requested": "Solicitado", + "fill_status_returned": "Regresado", + "fills": "Solicitudes y asignaciones", + "finalize": "Finalizar", + "finalized": "Finalizado.", + "fix_fields": "Algunos campos aún requieren atención.", + "forbidden": "Solo administradores del departamento", + "granted": "Concedida", + "hide_closed": "Ocultar despliegues cerrados", + "include_closed": "Mostrar despliegues cerrados", + "last_error": "Último error", + "last_read": "Última lectura", + "last_success": "Último éxito", + "load_failed": "No se pudo cargar ese registro.", + "loading": "Cargando registros…", + "location_kept": "Ubicación conservada", + "location_removed": "Ubicación eliminada", + "manage_on_web": "Los conectores se crean, modifican y reciben sus credenciales en la web.", + "minutes": "{{count}} min", + "needed_on": "Necesario", + "needs_attention": "Requiere su atención", + "never_read": "Nunca leído", + "new_record": "Nuevo registro", + "no_assignments": "No tiene nada asignado.", + "no_attachments": "Aún no hay adjuntos.", + "no_connectors": "Sin conectores", + "no_deployments": "Sin despliegues", + "no_drafts": "No tiene borradores.", + "no_fills": "No hay solicitudes en esta orden", + "no_recent": "No hay registros recientes.", + "no_reconciliation": "No hay discrepancias entre la fuente y sus registros", + "no_returned": "No se le ha devuelto nada.", + "no_rows": "Aún no hay filas.", + "no_runs": "Aún no hay ejecuciones", + "not_found": "Ese registro no está disponible.", + "not_granted": "No concedida", + "not_sent_yet": "Esperando para enviar", + "nothing_to_create": "Ahora mismo no se puede iniciar nada desde aquí.", + "offices": "Oficinas", + "offline_capable": "Funciona sin conexión", + "online_only": "Este formulario solo se completa en línea.", + "online_only_badge": "Solo en línea", + "open_artifact": "Abrir artefacto de origen", + "open_record": "Abrir registro", + "pending": "Sin enviar", + "poll_interval": "Intervalo de sondeo", + "preview": "Vista previa", + "purpose_acknowledge": "Confirme este registro", + "purpose_attach": "Añadir un adjunto", + "purpose_complete": "Complete este registro", + "purpose_correct": "Corrija este registro", + "purpose_review": "Revise este registro", + "read_authority": "Autoridad de lectura", + "read_feed_now": "Leer el feed ahora", + "reading_feed": "Leyendo…", + "reason_app_disabled": "Los registros no están activados para esta aplicación en su departamento.", + "reason_app_version_too_old": "Actualice la aplicación para usar los registros.", + "reason_module_disabled": "Los registros no están disponibles para su departamento.", + "reason_not_member": "No es un miembro activo de este departamento.", + "reason_records_not_usable": "Su departamento aún no ha activado los registros.", + "recent": "Recientes", + "reconciliation": "Conciliación", + "reconciliation_count": "{{count}} por conciliar", + "reconciliation_hint": "Dónde difieren la fuente y su registro. Nada se aplica automáticamente; un coordinador decide en la web.", + "reconciliation_local_fill_missing_in_source": "La asignación existe localmente pero no en la fuente", + "reconciliation_source_closed_local_open": "La fuente cerró la orden, pero hay asignaciones abiertas", + "reconciliation_source_released_local_not_returned": "La fuente liberó la orden, pero una asignación aún no ha regresado", + "reconciliation_source_request_missing_locally": "La solicitud existe en la fuente pero no localmente", + "reconciliation_source_status_ahead": "El estado de la fuente va por delante de la asignación local", + "reconciliation_source_status_behind": "El estado de la fuente va por detrás de la asignación local", + "refresh_catalog": "Actualizar", + "requests_this_hour": "Solicitudes esta hora", + "restricted": "Restringido", + "returned": "Devuelto a usted", + "run_counts": "Vistos {{seen}} · creados {{created}} · instantáneas {{snapshots}} · solicitudes {{requests}} · sin cambios {{unchanged}} · rechazados {{rejected}} · por conciliar {{conflicts}}", + "run_failed": "No se pudo leer el feed", + "run_finished": "Feed leído {{outcome}}: {{created}} creados, {{snapshots}} instantáneas, {{requests}} solicitudes, {{conflicts}} por conciliar", + "run_log": "Registro de ejecuciones", + "run_not_ok": "La lectura del feed terminó con {{outcome}}", + "run_outcome_disabled": "Desactivado", + "run_outcome_failed": "Fallido", + "run_outcome_ok": "Correcto", + "run_outcome_rate_limited": "Límite de solicitudes", + "run_outcome_rejected": "Rechazado", + "run_trigger_inbound": "Enviado por la fuente", + "run_trigger_manual": "Manual", + "run_trigger_poll": "Programado", + "save": "Guardar", + "save_and_send": "Guardar y enviar", + "save_failed": "No se pudo guardar.", + "save_locally": "Mantener en este dispositivo", + "saved": "Guardado.", + "saved_locally": "Guardado en este dispositivo. Se enviará cuando vuelva a estar en línea.", + "sign_statement": "Firmo este registro como autor", + "source": "Fuente", + "source_says": "Fuente:", + "submit_for_review": "Enviar para revisión", + "submitted": "Enviado para revisión.", + "take_photo": "Tomar una foto", + "terms": "Condiciones de la fuente", + "terms_acknowledged": "Aceptadas", + "terms_not_acknowledged": "Aún no aceptadas", + "title": "Registros", + "trainee": "En formación", + "unavailable": "Los registros no están disponibles en esta aplicación ahora mismo.", + "unit_code": "Unidad", + "unsupported_definition": "Este formulario usa un campo que esta versión de la aplicación no puede completar. Actualice la aplicación o use la aplicación web.", + "unsupported_field": "Esta aplicación aún no puede mostrar este campo. Use la aplicación web para completarlo.", + "untitled": "Sin título", + "update_required": "Se necesita actualizar la aplicación", + "write_authority": "Autoridad de escritura", + "write_refused": "Rechazada: nada se escribe de vuelta", + "your_record_says": "Su registro:" + }, "roles": { "assignedElsewhere": "En otra función", "clearAssignment": "Borrar la asignación actual", @@ -1117,6 +1731,17 @@ "version": "Versión", "website": "Sitio web" }, + "site_info": { + "additional_contact": "Contacto adicional", + "alert_notes": "Notas de alerta", + "empty": "Sin información del sitio", + "empty_description": "Ninguno de los contactos vinculados a esta llamada tiene preplan, peligros, notas de alerta o archivos.", + "entrance": "Entrada", + "files": "Archivos del sitio", + "load_failed": "No se pudo cargar la información del sitio.", + "loading": "Cargando información del sitio...", + "primary_contact": "Contacto principal" + }, "status": { "add_note": "Añadir Nota", "both_destinations_enabled": "Puede responder a llamadas o estaciones", @@ -1157,6 +1782,7 @@ "map": "Mapa", "notes": "Notas", "protocols": "Protocolos", + "records": "Registros", "routes": "Rutas", "settings": "Configuración", "weather_alerts": "Clima" @@ -1277,5 +1903,80 @@ "unknown": "Desconocido" } }, - "welcome": "Bienvenido al sitio de la aplicación Resgrid Unit" + "welcome": "Bienvenido al sitio de la aplicación Resgrid Unit", + "inventory": { + "title": "Inventario", + "titleFor": "Inventario · {{name}}", + "selectUnit": "Seleccione primero su unidad.", + "unnamedLocation": "Compartimento", + "unnamedItem": "Artículo", + "countName": "Revisión {{name}} {{date}}", + "notMigrated": "El inventario se está configurando para su organización.", + "disabled": "El inventario está desactivado para su organización.", + "noLocations": "Esta unidad no tiene inventario asignado.", + "startCount": "Contar el inventario", + "wholeUnit": "Unidad completa: {{name}}", + "pickLocation": "Dónde contar", + "startHelp": "Cuente lo que realmente hay en la unidad. Las diferencias se registran al completar el recuento.", + "start": "Iniciar recuento", + "cannotCount": "Contar requiere el permiso de ajustar inventario en esta unidad.", + "counts": "Recuentos recientes", + "count": "Recuento de inventario", + "empty": "Aquí no hay nada.", + "serial": "N.º de serie {{serial}}", + "quantity": "× {{quantity}}", + "back": "Atrás", + "completeTitle": "¿Completar el recuento?", + "completeBody": "{{count}} artículos difieren de lo esperado. Completar registra las diferencias.", + "completeNoChanges": "Todo coincide con lo esperado.", + "cancel": "Cancelar", + "complete": "Completar", + "cancelTitle": "¿Cancelar este recuento?", + "cancelBody": "No se registrará nada de lo contado.", + "keep": "Seguir contando", + "cancelCount": "Cancelar recuento", + "progress": "{{counted}} de {{total}} contados", + "differences": "{{count}} diferencias", + "awaitingWitness": "Cambió un artículo controlado; una segunda persona debe atestiguar este recuento en la web.", + "completed": "Recuento completado.", + "expected": "Esperado {{quantity}} {{unit}}", + "present": "Presente", + "missing": "Falta", + "less": "Uno menos", + "more": "Uno más", + "matches": "Coincide", + "save": "Guardar progreso", + "countStatus": { + "0": "En curso", + "1": "Pendiente de testigo", + "2": "Completado", + "3": "Cancelado" + }, + "assetStatus": { + "0": "En servicio", + "1": "Entregado", + "2": "En reparación", + "3": "Dañado", + "4": "Perdido", + "5": "Consumido", + "6": "Retirado" + }, + "errors": { + "retry": "No se pudo conectar con el servidor. Inténtelo de nuevo.", + "locked": "Desbloquee los datos protegidos para continuar.", + "PermissionRequired": "No tiene permiso para hacer esto.", + "MembershipRequired": "No es un miembro activo de esta organización.", + "InventoryDisabled": "El inventario está desactivado para su organización.", + "MigrationRequired": "El inventario se está configurando para su organización.", + "CountScopeTooLarge": "Demasiados artículos para un recuento. Cuente un compartimento cada vez.", + "CountEmpty": "Aquí no hay nada que contar.", + "CountSnapshotChanged": "El inventario de esta unidad cambió mientras contaba. Cancele el recuento y empiece de nuevo.", + "CountStateConflict": "Este recuento cambió en otro dispositivo. Vuelva a abrirlo e inténtelo de nuevo.", + "ReturnAssetFirst": "Un artículo marcado como faltante sigue entregado a alguien. Devuélvalo primero en la web.", + "ProtectedDataRequired": "Desbloquee los datos protegidos para continuar.", + "CountAlreadyExists": "Este recuento ya existe.", + "LocationUnavailable": "Esa ubicación no está disponible.", + "InvalidCount": "Revise los valores contados e inténtelo de nuevo." + } + } } diff --git a/src/translations/fr.json b/src/translations/fr.json index a22015e5..f9da7f36 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -126,6 +126,12 @@ "title": "Notes de l'appel" }, "call_detail": { + "activity_link": { + "auto": "Lié automatiquement", + "auto_hint": "Envoyé sans appel ; Resgrid l'a lié à partir du statut précédent ou de l'envoi.", + "inferred": "Déduit", + "inferred_hint": "Défini sans destination par une unité ou une personne envoyée sur cet appel pendant son intervention." + }, "address": "Adresse", "call_location": "Lieu de l'appel", "close_call": "Clôturer l'appel", @@ -201,6 +207,7 @@ "dispatched": "Envoyé", "info": "Info", "protocols": "Protocoles", + "site": "Infos site", "timeline": "Activité" }, "timestamp": "Horodatage", @@ -228,6 +235,9 @@ "contact_name_placeholder": "Saisir le nom du contact", "contact_phone": "Téléphone du contact", "contact_phone_placeholder": "Saisir le téléphone du contact", + "contact_picker": { + "linked": "Contact lié : {{name}}" + }, "coordinates": "Coordonnées GPS", "coordinates_found": "Coordonnées trouvées et adresse mise à jour", "coordinates_geocoding_error": "Échec de la récupération de l'adresse pour les coordonnées, mais localisation définie sur la carte", @@ -445,6 +455,206 @@ "type_sector_rotation": "Rotation de secteur", "type_unit": "Unité" }, + "checklists": { + "errors": { + "conflict": "Ce contrôle a changé sur le serveur. Votre travail local est conservé ; vérifiez-le avant de réessayer.", + "denied": "L’accès est indisponible. Votre travail local chiffré est conservé.", + "locked": "Déverrouillez les données protégées pour continuer.", + "offline": "Hors connexion : affichage des contrôles enregistrés en sécurité.", + "retry": "La synchronisation n’a pas abouti. Votre travail local est conservé.", + "storage_full": "La limite locale de brouillons ou de justificatifs est atteinte.", + "validation": "Vérifiez les réponses obligatoires, les dates, les notes et les justificatifs, puis réessayez." + }, + "labels": { + "Actions": "Opérations", + "Active": "Active", + "AnchorMonth": "Mois de référence", + "Annual": "Chaque année", + "Answer": "Réponse", + "Answered": "Répondu", + "AssetUnavailable": "Le matériel identifié par numéro de série est indisponible.", + "Assignment": "Attribué à", + "AssignmentAutomatic": "Attribution automatique", + "AssignmentPermission": "Vous ne faites pas partie des personnes affectées à ce contrôle.", + "AssignmentType1": "Personne", + "AssignmentType2": "Fonction du personnel", + "AssignmentType3": "Groupe ou centre", + "AssignmentType4": "Équipage de l’unité", + "AssignmentUnavailable": "Choisissez une affectation disponible dans cette organisation.", + "Attest": "Attester et terminer", + "Attestation": "Déclaration de vérification indépendante", + "Author": "Auteur", + "AwaitingWitness": "En attente d’un témoin", + "Back": "Retour", + "CalendarInclude": "Contrôles de disponibilité", + "CalendarRange": "Choisissez une période de calendrier de 93 jours au maximum.", + "Cancelled": "Annulé", + "Category": "Catégorie", + "Checkbox": "Case à cocher", + "Checked": "Cochée", + "ChecklistCompleted": "Liste de contrôle terminée", + "ChecklistFailed": "Échec de la liste de contrôle", + "ChecklistMissed": "Liste de contrôle non effectuée", + "ChecklistOccurrenceSkipped": "Dispense du contrôle planifié justifiée", + "ChecklistScheduleChanged": "Planification de contrôle modifiée", + "Checklists": "Listes de contrôle", + "Checks": "Contrôles", + "Choose": "Choisir", + "Critical": "Critique", + "Daily": "Chaque jour", + "Date": "Date", + "DayOfMonth": "Jour du mois", + "DeleteDraft": "Supprimer le brouillon", + "Department": "Service", + "Draft": "Brouillon", + "DraftGuidance": "Enregistrez un brouillon, puis publiez-le. Les exécutions existantes conservent leur version initiale. Ne saisissez aucune donnée de patient.", + "DueChecks": "Contrôles à effectuer", + "DueGuidance": "Contrôles prévus sur sept jours et contrôles inachevés. Les contrôles manqués peuvent encore être réalisés.", + "EditChecklist": "Éditeur de listes", + "EditDraft": "Modifier le brouillon", + "EditSchedule": "Modifier la planification", + "Evidence": "Preuve", + "ExcuseCheck": "Justifier la dispense", + "Export": "Exporter en JSON", + "Facility": "Installation", + "Fail": "Échoué", + "FailNote": "Un contrôle échoué exige une note.", + "Failed": "Échoué", + "Free": "Les listes sont gratuites pour tous les services.", + "Frequency": "Fréquence", + "Guidance": "Adaptez ces modèles à votre équipement, aux instructions du fabricant et aux exigences locales avant utilisation.", + "History": "Historique", + "InProgress": "En cours", + "Incomplete": "Incomplet", + "InventoryAsset": "Matériel identifié par numéro de série", + "Item": "Élément", + "Maintenance": "Maintenance et ordres de travail (Readiness Pro)", + "Missed": "Non effectué", + "MonthGuidance": "Pour les contrôles mensuels ou moins fréquents. Les mois courts utilisent leur dernier jour. Le mois de référence détermine les contrôles trimestriels, semestriels et annuels.", + "Monthly": "Chaque mois", + "Name": "Nom", + "NewChecklist": "Nouvelle liste", + "NewSchedule": "Nouvelle planification", + "Next": "Suivant", + "No": "Non", + "NoDefinitions": "Aucune liste sur cette page.", + "NoDueChecks": "Aucun contrôle dans cette liste.", + "NoResults": "Aucun modèle correspondant.", + "NoSchedules": "Aucune planification pour le moment.", + "NoScore": "Aucun score applicable", + "Note": "Note", + "OccurrenceUnavailable": "Ce contrôle est indisponible ou a déjà été modifié.", + "OpenCheck": "Ouvrir le contrôle", + "Optional": "Facultatif", + "Other": "Autre", + "Pass": "Réussi", + "Passed": "Réussi", + "PerShift": "À chaque vacation", + "Personnel": "Personnel", + "Photo": "Photo", + "Preview": "Aperçu du modèle", + "Previous": "Précédent", + "Print": "Imprimer", + "PublishDraft": "Publier le brouillon", + "PublishGuidance": "La publication crée une version immuable pour les nouvelles exécutions.", + "Published": "Publiée", + "PublishedVersion": "Version publiée", + "Quantity": "Quantité", + "Quarterly": "Chaque trimestre", + "ReminderBefore": "Minutes avant l’échéance (0 désactive)", + "ReminderDigest": "Regrouper les rappels en attente dans des récapitulatifs", + "ReminderEnabled": "Activer les rappels des listes de contrôle", + "ReminderEscalate": "Avertir les administrateurs du service après ce nombre de minutes de retard (vide désactive)", + "ReminderFixedTime": "Heure du récapitulatif quotidien (fuseau de l’organisation ; remplace le début de garde)", + "ReminderHelp": "Les rappels concernent les contrôles qui commencent après leur activation. Les messages contiennent un lien de connexion et respectent les préférences de notification de chaque personne. Les récapitulatifs regroupent les rappels en attente par destinataire.", + "ReminderMessage": "Des mises à jour des listes de contrôle sont disponibles. Connectez-vous pour consulter vos contrôles.", + "ReminderMissed": "Avertir le personnel responsable des contrôles manqués", + "ReminderSettings": "Rappels des listes de contrôle", + "ReminderShiftStart": "Envoyer un récapitulatif au début de garde", + "ReminderTimeValidation": "Saisissez une seule heure au format HH:mm.", + "ReminderTitle": "Rappels des listes de contrôle", + "ReminderValidation": "Saisissez un délai de rappel de 0 à 10080 minutes et un délai d’alerte des administrateurs de 1 à 43200 minutes, ou laissez ce dernier vide.", + "Remove": "Supprimer", + "ReportedLocation": "Position déclarée", + "Required": "Obligatoire", + "Result": "Résultat", + "Retire": "Retirer", + "Retired": "Retirée", + "RunGuidance": "Répondez à chaque élément applicable. Non applicable exige un motif. Enregistrez la progression pour reprendre plus tard. Les réponses et preuves soumises sont immuables.", + "SaveDraft": "Enregistrer le brouillon", + "SaveProgress": "Enregistrer la progression", + "SaveReminders": "Enregistrer les paramètres de rappel", + "SaveSchedule": "Enregistrer la planification", + "ScheduleEnabled": "Planification activée", + "ScheduleEndDate": "Date de fin (facultative)", + "ScheduleNotes": "Notes de planification", + "SchedulePaused": "En pause", + "SchedulePermission": "Vous ne pouvez pas gérer les planifications de contrôle.", + "ScheduleRequiresPublished": "Publiez la liste de contrôle avant de la planifier.", + "ScheduleStartDate": "Date de début", + "ScheduleTimeGuidance": "Les heures suivent ce fuseau. Une heure inexistante est décalée vers l’avant ; une heure répétée ne se produit qu’une fois.", + "ScheduleUnavailable": "Cette planification est indisponible.", + "ScheduleValidation": "Vérifiez les dates, heures, cible et récurrence de la planification.", + "ScheduleVersionGuidance": "L’enregistrement fixe la version publiée actuelle. Les modifications remplacent les futurs contrôles non commencés ; la pause annule les contrôles non commencés.", + "ScheduleWorkshift": "Vacation pour les contrôles par vacation", + "Scheduled": "Planifié", + "ScheduledFor": "Prévu pour", + "Schedules": "Planifications", + "Score": "Score", + "Search": "Rechercher", + "SearchLabel": "Trouver un modèle", + "SelectTarget": "Choisir une cible", + "SemiAnnual": "Tous les six mois", + "Signature": "Signature", + "SkipReason": "Motif de dispense du contrôle", + "SkipReasonRequired": "Saisissez un motif de 4 000 caractères au maximum.", + "Skipped": "Dispensé", + "StartRun": "Démarrer la liste", + "Started": "Début", + "State": "État", + "SubmitRun": "Soumettre la liste", + "Submitted": "Soumise", + "Target": "Cible", + "Templates": "Modèles de listes", + "TimeZone": "Fuseau horaire", + "TimesOfDay": "Heures locales", + "TimesOfDayGuidance": "Saisissez jusqu’à huit heures au format 24 heures, séparées par des virgules.", + "Unanswered": "Sans réponse", + "Unchecked": "Décochée", + "Unit": "Unité", + "Units": "Unités", + "Version": "Version", + "Weekly": "Chaque semaine", + "WeeklyDays": "Jours des contrôles hebdomadaires", + "WindowEnds": "Fin du délai", + "WindowMinutes": "Délai de réalisation (minutes)", + "Witness": "L'auteur authentifié et un autre membre authentifié doivent vérifier le résultat indépendamment.", + "WitnessInstructions": "Un autre membre authentifié doit vérifier indépendamment les réponses et preuves soumises. Partagez le lien de cette page avec cette personne.", + "WitnessLabel": "Témoin", + "WorkshiftFallback": "Utiliser les heures locales chaque jour", + "WorkshiftGuidance": "Uniquement pour les contrôles par vacation. Une vacation sélectionnée utilise ses dates de début enregistrées ; sinon, les heures locales s’appliquent chaque jour.", + "Yes": "Oui" + }, + "ui": { + "calendar": "Calendrier", + "clearSignature": "Effacer la signature", + "close": "Fermer", + "confirm": "Remplacer", + "editLocal": "Modifier le brouillon local", + "localDrafts": "Brouillons locaux", + "notApplicable": "Sans objet", + "prepareOffline": "Préparer pour une utilisation hors connexion", + "queued": "Enregistré en sécurité sur cet appareil ; synchronisation en attente.", + "reason": "Motif", + "refresh": "Actualiser", + "removeImage": "Supprimer l’image", + "replaceLocal": "Remplacer les réponses locales et les images en attente par la version du serveur ?", + "retry": "Réessayer la synchronisation", + "takePhoto": "Prendre une photo", + "unlock": "Déverrouiller les données protégées", + "useServer": "Utiliser la version du serveur" + } + }, "common": { "add": "Ajouter", "back": "Retour", @@ -536,6 +746,12 @@ "expires": "Expire", "facebook": "Facebook", "faxPhone": "Fax", + "files": { + "download_failed": "Le fichier n'a pas pu être téléchargé.", + "empty": "Aucun fichier n'est joint à ce contact.", + "loading": "Chargement des fichiers...", + "share_unavailable": "Le partage n'est pas disponible sur cet appareil." + }, "formError": "Veuillez corriger les erreurs dans le formulaire", "homePhone": "Téléphone domicile", "identification": "Identification", @@ -557,6 +773,48 @@ "otherInfo": "Autres informations", "person": "Personne", "phone": "Téléphone", + "preplan": { + "access": "Accès", + "access_notes": "Notes d'accès", + "alarm_company": "Société d'alarme", + "alarm_company_phone": "Téléphone de la société d'alarme", + "alarm_panel": "Centrale d'alarme", + "alert_flag": "Alerte", + "construction": "Construction · toiture · occupation", + "electric_shutoff": "Coupure électrique", + "emergency_contact": "Contact d'urgence", + "emergency_contact_phone": "Téléphone du contact d'urgence", + "gas_shutoff": "Coupure de gaz", + "gate_code": "Code du portail", + "general_hazard_notes": "Notes générales sur les dangers", + "general_hazards": "Dangers et tactique", + "hazards": "Dangers du site", + "hazmat_on_site": "Matières dangereuses sur place", + "knox_box": "Boîte à clés", + "last_reviewed": "Dernière révision", + "loading": "Chargement du plan d'intervention...", + "location": "Emplacement", + "nearest_hydrant": "Borne incendie la plus proche", + "never_reviewed": "Jamais révisé", + "next_review": "Prochaine révision", + "none": "Aucun plan d'intervention n'a été enregistré pour ce contact.", + "occupancy": "Construction et occupation", + "occupancy_hours": "Heures d'occupation", + "occupancy_notes": "Notes d'occupation", + "occupant_load": "Nombre d'occupants", + "occupants_needing_assistance": "Des occupants peuvent avoir besoin d'aide pour évacuer", + "on_site_contacts": "Contacts sur place", + "required_fire_flow": "Débit requis", + "review_overdue": "La date de révision du plan d'intervention est dépassée.", + "secondary_contact": "Contact secondaire", + "secondary_contact_phone": "Téléphone du contact secondaire", + "tactical_summary": "Résumé tactique", + "utilities": "Coupures des réseaux", + "utility_notes": "Notes sur les réseaux", + "water_shutoff": "Coupure d'eau", + "water_supply": "Alimentation en eau", + "water_supply_notes": "Notes sur l'alimentation en eau" + }, "public": "Public", "required": "Requis", "save": "Enregistrer le contact", @@ -569,7 +827,9 @@ "systemInformation": "Informations système", "tabs": { "details": "Détails", - "notes": "Notes" + "files": "Fichiers", + "notes": "Notes", + "preplan": "Plan d'intervention" }, "threads": "Threads", "title": "Contacts", @@ -578,7 +838,13 @@ "unknown_person": "Personne inconnue", "visibility": "Visibilité", "website": "Site web", - "zip": "Code postal" + "zip": "Code postal", + "withheld": "{{count}} informations protégées sont masquées.", + "categoryLabel": "Catégorie : {{name}}", + "physicalAddress": "Adresse physique", + "mailingAddress": "Adresse postale", + "hasGeofence": "Ce lieu a un géorepérage cartographié.", + "customFields": "Champs personnalisés" }, "data_protection": { "conceal": "Masquer à nouveau", @@ -813,11 +1079,193 @@ "select_all": "Tout sélectionner", "selected_count": "{{count}} sélectionnées", "title": "Notifications", - "unable_to_load": "Impossible de charger les notifications" + "unable_to_load": "Impossible de charger les notifications", + "detail_title": "Notification", + "additional_info": "Informations supplémentaires", + "view_call": "Voir l'appel", + "view_chat": "Voir la discussion", + "open_reference": "Ouvrir" }, "onboarding": { "message": "Bienvenue sur l'application Resgrid Unit" }, + "operations": { + "all": "Tous", + "back": "Retour", + "disabled": "Les déploiements ne sont pas activés pour ce service.", + "entryType": { + "0": "Déploiement", + "1": "Attente", + "2": "Déplacement" + }, + "errors": { + "denied": "Vous n'avez pas accès à ce déploiement.", + "disabled": "Cette fonction est désactivée pour votre service.", + "locked": "Déverrouillez les données protégées pour continuer.", + "retry": "Serveur injoignable. Réessayez.", + "validation": "Le serveur a refusé la modification.", + "timereports_subject_covered": "Cet équipage ou cette personne a déjà du temps sur un autre rapport ce jour-là.", + "timereports_date_exists": "Un rapport existe déjà pour cet équipage ou cette personne ce jour-là.", + "timereports_scope_invalid": "Cet équipage ou cette personne n'est pas actif sur ce déploiement.", + "timereports_locked": "Ce rapport ne peut plus être modifié.", + "timereports_time_invalid": "Saisissez les heures au format HH:mm.", + "deployments_closed": "Ce déploiement est clôturé.", + "expenses_receipt_invalid": "Le reçu n'a pas pu être lu.", + "deployments_attachment_too_large": "Le reçu est trop volumineux." + }, + "financeMode": { + "0": "Opérationnel", + "1": "Recouvrement des coûts", + "2": "Facturable" + }, + "issues": { + "end_before_start": "L'heure de fin doit être après l'heure de début.", + "missing_subject": "Choisissez à qui ou à quoi correspond cette entrée.", + "missing_time": "Saisissez une heure de début et de fin.", + "overlap": "Cette entrée chevauche une autre pour la même personne ou unité.", + "subject_outside_report_scope": "Cette entrée concerne quelqu'un hors de l'équipage ou de la personne de ce rapport.", + "subject_on_other_report": "Cette personne ou unité a déjà du temps sur un autre rapport le même jour.", + "subject_not_on_roster": "Cette entrée concerne quelqu'un qui n'est pas dans l'effectif.", + "entries_overlap": "Deux entrées de la même personne ou unité se chevauchent.", + "no_entries": "Ajoutez au moins une entrée.", + "break_rule": "Une longue vacation n'a aucune pause enregistrée.", + "long_travel": "Plus de déplacement qu'autorisé en une journée ; une approbation est nécessaire.", + "outside_report_date": "Cette entrée tombe en dehors de la date du rapport." + }, + "mars": { + "counts": "{{errors}} erreurs · {{warnings}} avertissements", + "draft": "Préparer le F-42 à partir des rapports de temps", + "f42": "F-42", + "help": "Le F-42 est préparé à partir des rapports de temps soumis et vérifié selon les règles du portail CAL OES. La soumission reste une étape manuelle et auditée sur le web.", + "none": "Aucun élément MARS pour ce déploiement pour le moment.", + "notReady": "Pas prêt pour le portail", + "ready": "Prêt pour le portail", + "record": "MARS {{id}} · {{status}}", + "title": "F-42", + "validate": "Valider" + }, + "noNumbers": "Aucun numéro d'incident ou de demande", + "none": "Aucun déploiement.", + "open": "Ouverts", + "phase": { + "0": "Mobilisation", + "1": "Attente", + "2": "Incident", + "3": "Retour" + }, + "reportStatus": { + "0": "Brouillon", + "1": "Soumis", + "2": "Approuvé", + "3": "Facturé", + "4": "Annulé" + }, + "roster": "{{units}} unités · {{personnel}} personnes", + "status": { + "0": "Planifié", + "1": "Attente", + "2": "Actif", + "3": "Démobilisation", + "4": "Terminé", + "5": "Annulé" + }, + "time": { + "accommodation": "Hébergement fourni par l'agence", + "addEntry": "Ajouter une entrée", + "from": "De", + "hours": "{{hours}} h", + "meals": "Repas fournis par l'agence", + "noEntries": "Aucune entrée pour le moment.", + "noReport": "Aucun rapport de temps pour ce jour.", + "notes": "Notes", + "remove": "Supprimer", + "report": "Rapport #{{number}}", + "save": "Enregistrer les entrées", + "start": "Commencer le rapport de temps", + "subject": "Qui ou quoi", + "submit": "Soumettre le rapport", + "title": "Temps", + "to": "À", + "total": "Total {{hours}} h", + "type": "Type d'entrée", + "unpaidBreak": "Pause non payée (min)", + "crewReport": "Rapport de temps d'équipage #{{number}} · {{name}}", + "individualReport": "Rapport de temps #{{number}} · {{name}}", + "noCrewReport": "Aucun rapport d'équipage pour {{name}} ce jour-là.", + "coveredBy": "Ce temps figure déjà sur le rapport #{{number}} de ce jour.", + "startCrew": "Commencer le rapport d'équipage", + "readOnly": "Vous pouvez consulter ce rapport mais pas le modifier.", + "nextDay": "Se termine le lendemain", + "applyToCrew": "Appliquer à l'équipage", + "approve": "Approuver le rapport", + "dayReports": "Rapports de ce jour", + "awaitingApproval": "En attente d'approbation ({{count}})", + "notOnRoster": "Vous ne faites pas partie de l'effectif ni d'un équipage de ce déploiement." + }, + "title": "Déploiements", + "usage": { + "add": "Ajouter un relevé", + "endOdometer": "Compteur fin", + "engineHours": "Heures moteur", + "fuel": "Carburant", + "needsReview": "À vérifier", + "phase": "Phase", + "saved": "Relevé enregistré.", + "startOdometer": "Compteur début", + "title": "Utilisation", + "unit": "Unité" + }, + "crewOf": "Équipage de {{names}}", + "previousDay": "Jour précédent", + "nextDay": "Jour suivant", + "scope": { + "deployment": "Tout le déploiement", + "mine": "Mon temps", + "crew": "Équipage : {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "Signatures", + "crewBossSigned": "Signé par le chef d'équipage {{when}}", + "crewBossPending": "Pas encore signé par le chef d'équipage.", + "customerSigned": "Signataire de l'organisme : {{name}}", + "customerPending": "Aucune signature de l'organisme enregistrée.", + "asCrewBoss": "Signer en tant que chef d'équipage", + "customerName": "Nom du signataire de l'organisme", + "recordCustomer": "Enregistrer la signature de l'organisme" + }, + "expenses": { + "title": "Dépenses", + "type": { + "0": "Indemnité de repas", + "1": "Hébergement", + "2": "Hébergement privé", + "3": "Traversier", + "4": "Carburant", + "5": "Réapprovisionnement", + "6": "Autre" + }, + "typeLabel": "Type de dépense", + "amount": "Montant ({{currency}})", + "description": "Description", + "city": "Ville", + "takePhoto": "Prendre une photo", + "choosePhoto": "Choisir une photo", + "receiptAttached": "Photo du reçu jointe.", + "save": "Ajouter la dépense", + "add": "Nouvelle dépense", + "noneToday": "Aucune dépense ce jour-là.", + "otherDays": "Autres jours", + "hasReceipt": "reçu", + "remove": "Supprimer", + "removeTitle": "Supprimer la dépense ?", + "removeBody": "Cela supprime la dépense et son reçu du déploiement.", + "cancel": "Annuler", + "saved": "Dépense enregistrée.", + "amountInvalid": "Saisissez un montant comme 42.50.", + "photoDenied": "Autorisez l'accès à l'appareil photo ou aux photos pour joindre un reçu." + } + }, "protocols": { "details": { "close": "Fermer", @@ -846,6 +1294,172 @@ "unknown_type_warning": "Ce type de notification n'est pas reconnu. Veuillez contacter le support si vous continuez à voir ce message.", "view_call": "Voir l'appel" }, + "records": { + "add_row": "Ajouter une ligne", + "agencies": "Agences", + "agreement": "Accord", + "assigned": "Qui vous sont attribués", + "attachment_failed": "Cette pièce jointe n'a pas pu être envoyée.", + "attachment_file_missing": "Le fichier n'est plus sur cet appareil.", + "attachment_unreadable": "Ce fichier n'a pas pu être lu.", + "attachment_will_resume": "En pause. La reprise se fera où elle s'est arrêtée.", + "attachments": "Pièces jointes", + "attachments_hint": "Les pièces jointes sont ajoutées après l'enregistrement.", + "attestation": "J'atteste que cet enregistrement est complet et exact", + "back": "Retour", + "busy": "Une lecture est déjà en cours", + "camera_denied": "L'appareil photo n'est pas disponible sans autorisation.", + "cancel": "Annuler", + "captured_on": "Capturé", + "choose": "Choisir…", + "choose_file": "Choisir un fichier", + "choose_photo": "Choisir une photo", + "closed_out_on": "Clôturé le", + "closeout_notes": "Notes de clôture", + "conflict_app_version": "Les enregistrements ne sont pas activés pour cette version de l'application.", + "conflict_definition_retired": "Ce formulaire a été retiré. Recommencez depuis la liste actuelle.", + "conflict_etag": "Quelqu'un d'autre a modifié cet enregistrement. Rechargez-le et réappliquez vos modifications.", + "conflict_permission": "Vous n'avez plus accès à cet enregistrement.", + "conflict_protected_data": "Ce formulaire nécessite une connexion active et votre accès aux données protégées.", + "conflict_scope": "Vos accès ont changé, cet enregistrement a donc été rechargé.", + "connector_disabled": "Désactivé", + "connector_maintained": "Maintenu par un connecteur", + "connector_not_ready": "Pas prêt", + "connector_ready": "Prêt", + "connectors": "Connecteurs externes", + "connectors_hint": "Chaque connecteur lit un système de commande selon ses conditions, avec un identifiant chiffré et une limite de requêtes. L'autorité d'écriture est refusée.", + "connectors_home_hint": "Flux des systèmes de commande et ce qui reste à rapprocher", + "cost_code": "Code de coût", + "deployment_status_closed_out": "Clôturé", + "deployment_status_mobilized": "Mobilisé", + "deployment_status_open": "Ouvert", + "deployment_status_released": "Libéré", + "deployments": "Déploiements", + "deployments_hint": "Le système de commande reste l'autorité. Les affectations sont avancées par un coordinateur sur le web ; rien ici n'en modifie une.", + "deployments_home_hint": "Ordres de ressources externes que votre service pourvoit", + "drafts": "Brouillons", + "field_required": "Ce champ est obligatoire.", + "fill_status_accepted": "Accepté", + "fill_status_assigned": "Affecté", + "fill_status_checked_in": "Enregistré", + "fill_status_declined": "Refusé", + "fill_status_demobilized": "Démobilisé", + "fill_status_mobilized": "Mobilisé", + "fill_status_released": "Libéré", + "fill_status_requested": "Demandé", + "fill_status_returned": "Rentré", + "fills": "Demandes et affectations", + "finalize": "Finaliser", + "finalized": "Finalisé.", + "fix_fields": "Certains champs demandent encore votre attention.", + "forbidden": "Réservé aux administrateurs du service", + "granted": "Accordée", + "hide_closed": "Masquer les déploiements clôturés", + "include_closed": "Afficher les déploiements clôturés", + "last_error": "Dernière erreur", + "last_read": "Dernière lecture", + "last_success": "Dernier succès", + "load_failed": "Impossible de charger cet enregistrement.", + "loading": "Chargement des enregistrements…", + "location_kept": "Localisation conservée", + "location_removed": "Localisation supprimée", + "manage_on_web": "Les connecteurs sont créés, modifiés et dotés de leurs identifiants sur le web.", + "minutes": "{{count}} min", + "needed_on": "Requis", + "needs_attention": "Nécessite votre attention", + "never_read": "Jamais lu", + "new_record": "Nouvel enregistrement", + "no_assignments": "Rien ne vous est attribué.", + "no_attachments": "Aucune pièce jointe pour l'instant.", + "no_connectors": "Aucun connecteur", + "no_deployments": "Aucun déploiement", + "no_drafts": "Vous n'avez aucun brouillon.", + "no_fills": "Aucune demande sur cet ordre", + "no_recent": "Aucun enregistrement récent.", + "no_reconciliation": "Aucune divergence entre la source et vos dossiers", + "no_returned": "Rien ne vous a été renvoyé.", + "no_rows": "Aucune ligne pour l'instant.", + "no_runs": "Aucune exécution pour l'instant", + "not_found": "Cet enregistrement n'est pas disponible.", + "not_granted": "Non accordée", + "not_sent_yet": "En attente d'envoi", + "nothing_to_create": "Rien ne peut être démarré ici pour le moment.", + "offices": "Bureaux", + "offline_capable": "Fonctionne hors ligne", + "online_only": "Ce formulaire se remplit uniquement en ligne.", + "online_only_badge": "En ligne uniquement", + "open_artifact": "Ouvrir l'artefact source", + "open_record": "Ouvrir le dossier", + "pending": "Non envoyé", + "poll_interval": "Intervalle d'interrogation", + "preview": "Aperçu", + "purpose_acknowledge": "Accuser réception de cet enregistrement", + "purpose_attach": "Ajouter une pièce jointe", + "purpose_complete": "Compléter cet enregistrement", + "purpose_correct": "Corriger cet enregistrement", + "purpose_review": "Réviser cet enregistrement", + "read_authority": "Autorité de lecture", + "read_feed_now": "Lire le flux maintenant", + "reading_feed": "Lecture…", + "reason_app_disabled": "Les enregistrements ne sont pas activés pour cette application dans votre service.", + "reason_app_version_too_old": "Mettez à jour l'application pour utiliser les enregistrements.", + "reason_module_disabled": "Les enregistrements ne sont pas disponibles pour votre service.", + "reason_not_member": "Vous n'êtes pas un membre actif de ce service.", + "reason_records_not_usable": "Votre service n'a pas encore activé les enregistrements.", + "recent": "Récents", + "reconciliation": "Rapprochement", + "reconciliation_count": "{{count}} à rapprocher", + "reconciliation_hint": "Là où la source et votre dossier divergent. Rien n'est appliqué automatiquement ; un coordinateur décide sur le web.", + "reconciliation_local_fill_missing_in_source": "L'affectation existe en local mais pas dans la source", + "reconciliation_source_closed_local_open": "La source a clôturé l'ordre, mais des affectations sont encore ouvertes", + "reconciliation_source_released_local_not_returned": "La source a libéré l'ordre, mais une affectation n'est pas encore rentrée", + "reconciliation_source_request_missing_locally": "La demande existe dans la source mais pas en local", + "reconciliation_source_status_ahead": "Le statut de la source est en avance sur l'affectation locale", + "reconciliation_source_status_behind": "Le statut de la source est en retard sur l'affectation locale", + "refresh_catalog": "Actualiser", + "requests_this_hour": "Requêtes cette heure", + "restricted": "Restreint", + "returned": "Renvoyés à vous", + "run_counts": "Vus {{seen}} · créés {{created}} · instantanés {{snapshots}} · demandes {{requests}} · inchangés {{unchanged}} · rejetés {{rejected}} · à rapprocher {{conflicts}}", + "run_failed": "Le flux n'a pas pu être lu", + "run_finished": "Flux lu {{outcome}} : {{created}} créés, {{snapshots}} instantanés, {{requests}} demandes, {{conflicts}} à rapprocher", + "run_log": "Journal des exécutions", + "run_not_ok": "La lecture du flux s'est terminée par {{outcome}}", + "run_outcome_disabled": "Désactivé", + "run_outcome_failed": "Échec", + "run_outcome_ok": "OK", + "run_outcome_rate_limited": "Limite de requêtes atteinte", + "run_outcome_rejected": "Rejeté", + "run_trigger_inbound": "Poussé par la source", + "run_trigger_manual": "Manuel", + "run_trigger_poll": "Planifié", + "save": "Enregistrer", + "save_and_send": "Enregistrer et envoyer", + "save_failed": "Impossible d'enregistrer.", + "save_locally": "Conserver sur cet appareil", + "saved": "Enregistré.", + "saved_locally": "Conservé sur cet appareil. L'envoi se fera dès votre retour en ligne.", + "sign_statement": "Je signe cet enregistrement en tant qu'auteur", + "source": "Source", + "source_says": "Source :", + "submit_for_review": "Soumettre pour révision", + "submitted": "Envoyé pour révision.", + "take_photo": "Prendre une photo", + "terms": "Conditions de la source", + "terms_acknowledged": "Reconnues", + "terms_not_acknowledged": "Pas encore reconnues", + "title": "Enregistrements", + "trainee": "Stagiaire", + "unavailable": "Les enregistrements ne sont pas disponibles dans cette application pour le moment.", + "unit_code": "Unité", + "unsupported_definition": "Ce formulaire utilise un champ que cette version de l'application ne peut pas remplir. Mettez à jour l'application ou utilisez l'application web.", + "unsupported_field": "Cette application ne peut pas encore afficher ce champ. Utilisez l'application web pour le remplir.", + "untitled": "Sans titre", + "update_required": "Mise à jour de l'application requise", + "write_authority": "Autorité d'écriture", + "write_refused": "Refusée — rien n'est réécrit", + "your_record_says": "Votre dossier :" + }, "roles": { "assignedElsewhere": "Dans un autre rôle", "clearAssignment": "Effacer l'attribution actuelle", @@ -1117,6 +1731,17 @@ "version": "Version", "website": "Site web" }, + "site_info": { + "additional_contact": "Contact supplémentaire", + "alert_notes": "Notes d'alerte", + "empty": "Aucune information sur le site", + "empty_description": "Aucun contact lié à cet appel n'a de plan d'intervention, de dangers, de notes d'alerte ou de fichiers.", + "entrance": "Entrée", + "files": "Fichiers du site", + "load_failed": "Les informations du site n'ont pas pu être chargées.", + "loading": "Chargement des informations du site...", + "primary_contact": "Contact principal" + }, "status": { "add_note": "Ajouter une note", "both_destinations_enabled": "Peut répondre aux appels ou aux stations", @@ -1157,6 +1782,7 @@ "map": "Carte", "notes": "Notes", "protocols": "Protocoles", + "records": "Enregistrements", "routes": "Itinéraires", "settings": "Paramètres", "weather_alerts": "Météo" @@ -1277,5 +1903,80 @@ "unknown": "Inconnu" } }, - "welcome": "Bienvenue sur l'application Resgrid Unit" + "welcome": "Bienvenue sur l'application Resgrid Unit", + "inventory": { + "title": "Inventaire", + "titleFor": "Inventaire · {{name}}", + "selectUnit": "Sélectionnez d'abord votre véhicule.", + "unnamedLocation": "Coffre", + "unnamedItem": "Article", + "countName": "Contrôle {{name}} {{date}}", + "notMigrated": "L'inventaire est en cours de configuration pour votre organisation.", + "disabled": "L'inventaire est désactivé pour votre organisation.", + "noLocations": "Aucun inventaire n'est affecté à ce véhicule.", + "startCount": "Compter l'inventaire", + "wholeUnit": "Véhicule entier : {{name}}", + "pickLocation": "Où compter", + "startHelp": "Comptez ce qui est réellement sur le véhicule. Les écarts sont enregistrés à la fin du comptage.", + "start": "Commencer le comptage", + "cannotCount": "Le comptage nécessite l'autorisation d'ajuster l'inventaire sur ce véhicule.", + "counts": "Comptages récents", + "count": "Comptage d'inventaire", + "empty": "Rien ici.", + "serial": "N° de série {{serial}}", + "quantity": "× {{quantity}}", + "back": "Retour", + "completeTitle": "Terminer le comptage ?", + "completeBody": "{{count}} articles diffèrent de l'attendu. Terminer enregistre les écarts.", + "completeNoChanges": "Tout correspond à l'attendu.", + "cancel": "Annuler", + "complete": "Terminer", + "cancelTitle": "Annuler ce comptage ?", + "cancelBody": "Rien de ce qui a été compté n'est enregistré.", + "keep": "Continuer le comptage", + "cancelCount": "Annuler le comptage", + "progress": "{{counted}} sur {{total}} comptés", + "differences": "{{count}} écarts", + "awaitingWitness": "Un article contrôlé a changé ; une seconde personne doit attester ce comptage sur le web.", + "completed": "Comptage terminé.", + "expected": "Attendu {{quantity}} {{unit}}", + "present": "Présent", + "missing": "Manquant", + "less": "Un de moins", + "more": "Un de plus", + "matches": "Conforme", + "save": "Enregistrer la progression", + "countStatus": { + "0": "En cours", + "1": "En attente de témoin", + "2": "Terminé", + "3": "Annulé" + }, + "assetStatus": { + "0": "En service", + "1": "Remis", + "2": "En réparation", + "3": "Endommagé", + "4": "Perdu", + "5": "Consommé", + "6": "Retiré" + }, + "errors": { + "retry": "Impossible de joindre le serveur. Réessayez.", + "locked": "Déverrouillez les données protégées pour continuer.", + "PermissionRequired": "Vous n'avez pas l'autorisation de faire cela.", + "MembershipRequired": "Vous n'êtes pas membre actif de cette organisation.", + "InventoryDisabled": "L'inventaire est désactivé pour votre organisation.", + "MigrationRequired": "L'inventaire est en cours de configuration pour votre organisation.", + "CountScopeTooLarge": "Trop d'articles pour un comptage. Comptez un coffre à la fois.", + "CountEmpty": "Il n'y a rien à compter ici.", + "CountSnapshotChanged": "L'inventaire de ce véhicule a changé pendant le comptage. Annulez et recommencez.", + "CountStateConflict": "Ce comptage a changé sur un autre appareil. Rouvrez-le et réessayez.", + "ReturnAssetFirst": "Un article marqué manquant est encore remis à quelqu'un. Rendez-le d'abord sur le web.", + "ProtectedDataRequired": "Déverrouillez les données protégées pour continuer.", + "CountAlreadyExists": "Ce comptage existe déjà.", + "LocationUnavailable": "Cet emplacement n'est pas disponible.", + "InvalidCount": "Vérifiez les valeurs comptées et réessayez." + } + } } diff --git a/src/translations/it.json b/src/translations/it.json index 1e4cf26d..e52cf72d 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -126,6 +126,12 @@ "title": "Note chiamata" }, "call_detail": { + "activity_link": { + "auto": "Collegato automaticamente", + "auto_hint": "Inviato senza chiamata; Resgrid lo ha collegato dallo stato precedente o dall'invio.", + "inferred": "Dedotto", + "inferred_hint": "Impostato senza destinazione da un'unità o una persona inviata a questa chiamata mentre vi lavorava." + }, "address": "Indirizzo", "call_location": "Posizione chiamata", "close_call": "Chiudi chiamata", @@ -201,6 +207,7 @@ "dispatched": "Inviato", "info": "Info", "protocols": "Protocolli", + "site": "Info sito", "timeline": "Attività" }, "timestamp": "Timestamp", @@ -228,6 +235,9 @@ "contact_name_placeholder": "Inserisci il nome del contatto", "contact_phone": "Telefono contatto", "contact_phone_placeholder": "Inserisci il telefono del contatto", + "contact_picker": { + "linked": "Contatto collegato: {{name}}" + }, "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", @@ -445,6 +455,206 @@ "type_sector_rotation": "Rotazione settore", "type_unit": "Unità" }, + "checklists": { + "errors": { + "conflict": "Il controllo è cambiato sul server. Il lavoro locale viene conservato; verificalo prima di riprovare.", + "denied": "L’accesso non è disponibile. Il lavoro locale cifrato viene conservato.", + "locked": "Sblocca i dati protetti per continuare.", + "offline": "Senza connessione: vengono mostrati i controlli salvati in modo sicuro.", + "retry": "Impossibile completare la sincronizzazione. Il lavoro locale viene conservato.", + "storage_full": "È stato raggiunto il limite locale di bozze o prove.", + "validation": "Controlla le risposte obbligatorie, le date, le note e le prove, quindi riprova." + }, + "labels": { + "Actions": "Azioni", + "Active": "Attiva", + "AnchorMonth": "Mese di riferimento", + "Annual": "Ogni anno", + "Answer": "Risposta", + "Answered": "Con risposta", + "AssetUnavailable": "L’attrezzatura con numero di serie non è disponibile.", + "Assignment": "Assegnato a", + "AssignmentAutomatic": "Assegnazione automatica", + "AssignmentPermission": "Non sei assegnato all’esecuzione di questo controllo.", + "AssignmentType1": "Persona", + "AssignmentType2": "Ruolo del personale", + "AssignmentType3": "Gruppo o sede", + "AssignmentType4": "Equipaggio dell’unità", + "AssignmentUnavailable": "Scegli un’assegnazione disponibile in questa organizzazione.", + "Attest": "Attesta e completa", + "Attestation": "Dichiarazione di verifica indipendente", + "Author": "Autore", + "AwaitingWitness": "In attesa del testimone", + "Back": "Indietro", + "CalendarInclude": "Controlli di prontezza operativa", + "CalendarRange": "Scegli un intervallo di calendario non superiore a 93 giorni.", + "Cancelled": "Annullato", + "Category": "Categoria", + "Checkbox": "Casella di controllo", + "Checked": "Selezionata", + "ChecklistCompleted": "Lista di controllo completata", + "ChecklistFailed": "Lista di controllo non superata", + "ChecklistMissed": "Lista di controllo non eseguita", + "ChecklistOccurrenceSkipped": "Omissione del controllo pianificato giustificata", + "ChecklistScheduleChanged": "Pianificazione del controllo modificata", + "Checklists": "Liste di controllo", + "Checks": "Controlli", + "Choose": "Seleziona", + "Critical": "Critico", + "Daily": "Ogni giorno", + "Date": "Data", + "DayOfMonth": "Giorno del mese", + "DeleteDraft": "Elimina bozza", + "Department": "Reparto", + "Draft": "Bozza", + "DraftGuidance": "Salva una bozza e pubblicala. Le esecuzioni esistenti mantengono la versione originale. Non inserire dati dei pazienti.", + "DueChecks": "Controlli da eseguire", + "DueGuidance": "Controlli previsti per sette giorni e controlli incompleti. I controlli mancati possono ancora essere completati.", + "EditChecklist": "Editor delle liste", + "EditDraft": "Modifica bozza", + "EditSchedule": "Modifica pianificazione", + "Evidence": "Prova", + "ExcuseCheck": "Giustifica omissione", + "Export": "Esporta JSON", + "Facility": "Struttura", + "Fail": "Non superato", + "FailNote": "Una verifica non superata richiede una nota.", + "Failed": "Non superato", + "Free": "Le liste sono gratuite per tutti i reparti.", + "Frequency": "Frequenza", + "Guidance": "Adatta i modelli alle attrezzature, alle procedure del produttore e ai requisiti locali prima dell'uso.", + "History": "Cronologia", + "InProgress": "In corso", + "Incomplete": "Incompleto", + "InventoryAsset": "Attrezzatura con numero di serie", + "Item": "Elemento", + "Maintenance": "Manutenzione e ordini di lavoro (Readiness Pro)", + "Missed": "Non eseguito", + "MonthGuidance": "Per controlli mensili o meno frequenti. Nei mesi corti si usa l’ultimo giorno. Il mese di riferimento determina i controlli trimestrali, semestrali e annuali.", + "Monthly": "Ogni mese", + "Name": "Nome", + "NewChecklist": "Nuova lista", + "NewSchedule": "Nuova pianificazione", + "Next": "Successivo", + "No": "No", + "NoDefinitions": "Nessuna lista in questa pagina.", + "NoDueChecks": "Nessun controllo in questo elenco.", + "NoResults": "Nessun modello corrispondente.", + "NoSchedules": "Non ci sono ancora pianificazioni.", + "NoScore": "Nessun punteggio applicabile", + "Note": "Nota", + "OccurrenceUnavailable": "Questo controllo non è disponibile o è già stato modificato.", + "OpenCheck": "Apri controllo", + "Optional": "Facoltativo", + "Other": "Altro", + "Pass": "Superato", + "Passed": "Superato", + "PerShift": "A ogni turno", + "Personnel": "Personale", + "Photo": "Foto", + "Preview": "Anteprima del modello", + "Previous": "Precedente", + "Print": "Stampa", + "PublishDraft": "Pubblica bozza", + "PublishGuidance": "La pubblicazione crea una versione immutabile per le nuove esecuzioni.", + "Published": "Pubblicata", + "PublishedVersion": "Versione pubblicata", + "Quantity": "Quantità", + "Quarterly": "Ogni trimestre", + "ReminderBefore": "Minuti prima della scadenza (0 disattiva)", + "ReminderDigest": "Raggruppa i promemoria in attesa nei riepiloghi", + "ReminderEnabled": "Attiva i promemoria delle liste di controllo", + "ReminderEscalate": "Avvisa gli amministratori del reparto dopo questi minuti di ritardo (vuoto disattiva)", + "ReminderFixedTime": "Ora del riepilogo giornaliero (fuso orario dell’organizzazione; sostituisce l’inizio turno)", + "ReminderHelp": "I promemoria si applicano ai controlli che iniziano dopo l’attivazione. I messaggi contengono un collegamento per accedere e rispettano le preferenze di notifica di ciascuna persona. I riepiloghi raggruppano i promemoria in attesa per destinatario.", + "ReminderMessage": "Sono disponibili aggiornamenti delle liste di controllo. Accedi per consultare i tuoi controlli.", + "ReminderMissed": "Avvisa il personale responsabile dei controlli mancati", + "ReminderSettings": "Promemoria delle liste di controllo", + "ReminderShiftStart": "Invia un riepilogo all’inizio del turno", + "ReminderTimeValidation": "Inserisci un solo orario nel formato HH:mm.", + "ReminderTitle": "Promemoria delle liste di controllo", + "ReminderValidation": "Inserisci un intervallo di 0–10080 minuti per i promemoria e di 1–43200 minuti per l’avviso agli amministratori, oppure lascia vuoto quest’ultimo.", + "Remove": "Rimuovi", + "ReportedLocation": "Posizione dichiarata", + "Required": "Obbligatorio", + "Result": "Risultato", + "Retire": "Ritira", + "Retired": "Ritirata", + "RunGuidance": "Rispondi a ogni elemento applicabile. Non applicabile richiede un motivo. Salva i progressi per riprendere in seguito. Le risposte e le prove inviate non possono essere modificate.", + "SaveDraft": "Salva bozza", + "SaveProgress": "Salva progressi", + "SaveReminders": "Salva le impostazioni dei promemoria", + "SaveSchedule": "Salva pianificazione", + "ScheduleEnabled": "Pianificazione abilitata", + "ScheduleEndDate": "Data di fine (facoltativa)", + "ScheduleNotes": "Note della pianificazione", + "SchedulePaused": "In pausa", + "SchedulePermission": "Non puoi gestire le pianificazioni dei controlli.", + "ScheduleRequiresPublished": "Pubblica la lista di controllo prima di pianificarla.", + "ScheduleStartDate": "Data di inizio", + "ScheduleTimeGuidance": "Gli orari seguono questo fuso. Un orario inesistente viene spostato in avanti; un orario ripetuto si verifica una sola volta.", + "ScheduleUnavailable": "Questa pianificazione non è disponibile.", + "ScheduleValidation": "Verifica date, orari, destinatario e ricorrenza della pianificazione.", + "ScheduleVersionGuidance": "Il salvataggio fissa la versione pubblicata corrente. Le modifiche sostituiscono i controlli futuri non iniziati; la pausa annulla quelli non iniziati.", + "ScheduleWorkshift": "Turno per i controlli a ogni turno", + "Scheduled": "Pianificato", + "ScheduledFor": "Previsto per", + "Schedules": "Pianificazioni", + "Score": "Punteggio", + "Search": "Cerca", + "SearchLabel": "Trova un modello", + "SelectTarget": "Seleziona una destinazione", + "SemiAnnual": "Ogni sei mesi", + "Signature": "Firma", + "SkipReason": "Motivo dell'omissione giustificata", + "SkipReasonRequired": "Inserisci un motivo di massimo 4.000 caratteri.", + "Skipped": "Omissione giustificata", + "StartRun": "Avvia lista", + "Started": "Avvio", + "State": "Stato", + "SubmitRun": "Invia lista", + "Submitted": "Inviata", + "Target": "Destinazione", + "Templates": "Modelli di liste", + "TimeZone": "Fuso orario", + "TimesOfDay": "Orari locali", + "TimesOfDayGuidance": "Inserisci fino a otto orari nel formato a 24 ore, separati da virgole.", + "Unanswered": "Senza risposta", + "Unchecked": "Non selezionata", + "Unit": "Unità operativa", + "Units": "Unità", + "Version": "Versione", + "Weekly": "Ogni settimana", + "WeeklyDays": "Giorni dei controlli settimanali", + "WindowEnds": "Scadenza del termine", + "WindowMinutes": "Tempo di completamento (minuti)", + "Witness": "L'autore autenticato e un altro membro autenticato devono verificare il risultato in modo indipendente.", + "WitnessInstructions": "Un altro membro autenticato deve verificare in modo indipendente le risposte e le prove inviate. Condividi il collegamento a questa pagina con tale persona.", + "WitnessLabel": "Testimone", + "WorkshiftFallback": "Usa gli orari locali ogni giorno", + "WorkshiftGuidance": "Solo per controlli a ogni turno. Un turno selezionato usa le date di inizio registrate; altrimenti si usano gli orari locali ogni giorno.", + "Yes": "Sì" + }, + "ui": { + "calendar": "Calendario", + "clearSignature": "Cancella firma", + "close": "Chiudi", + "confirm": "Sostituisci", + "editLocal": "Modifica bozza locale", + "localDrafts": "Bozze locali", + "notApplicable": "Non applicabile", + "prepareOffline": "Prepara per l’uso senza connessione", + "queued": "Salvato in modo sicuro sul dispositivo; sincronizzazione in attesa.", + "reason": "Motivo", + "refresh": "Aggiorna", + "removeImage": "Rimuovi immagine", + "replaceLocal": "Sostituire le risposte locali e le immagini in attesa con la versione del server?", + "retry": "Riprova sincronizzazione", + "takePhoto": "Scatta foto", + "unlock": "Sblocca dati protetti", + "useServer": "Usa versione del server" + } + }, "common": { "add": "Aggiungi", "back": "Indietro", @@ -536,6 +746,12 @@ "expires": "Scade", "facebook": "Facebook", "faxPhone": "Fax", + "files": { + "download_failed": "Impossibile scaricare il file.", + "empty": "Nessun file allegato a questo contatto.", + "loading": "Caricamento file...", + "share_unavailable": "La condivisione non è disponibile su questo dispositivo." + }, "formError": "Correggere gli errori nel modulo", "homePhone": "Telefono casa", "identification": "Identificazione", @@ -557,6 +773,48 @@ "otherInfo": "Altre informazioni", "person": "Persona", "phone": "Telefono", + "preplan": { + "access": "Accesso", + "access_notes": "Note di accesso", + "alarm_company": "Società di allarme", + "alarm_company_phone": "Telefono società di allarme", + "alarm_panel": "Centrale di allarme", + "alert_flag": "Allerta", + "construction": "Costruzione · tetto · occupazione", + "electric_shutoff": "Interruzione elettrica", + "emergency_contact": "Contatto di emergenza", + "emergency_contact_phone": "Telefono contatto di emergenza", + "gas_shutoff": "Chiusura gas", + "gate_code": "Codice cancello", + "general_hazard_notes": "Note generali sui pericoli", + "general_hazards": "Pericoli e tattiche", + "hazards": "Pericoli della struttura", + "hazmat_on_site": "Materiali pericolosi in loco", + "knox_box": "Cassetta chiavi", + "last_reviewed": "Ultima revisione", + "loading": "Caricamento piano preventivo...", + "location": "Posizione", + "nearest_hydrant": "Idrante più vicino", + "never_reviewed": "Mai revisionato", + "next_review": "Prossima revisione", + "none": "Nessun piano preventivo registrato per questo contatto.", + "occupancy": "Costruzione e occupazione", + "occupancy_hours": "Orari di occupazione", + "occupancy_notes": "Note sull'occupazione", + "occupant_load": "Numero di occupanti", + "occupants_needing_assistance": "Gli occupanti potrebbero aver bisogno di aiuto per evacuare", + "on_site_contacts": "Contatti in loco", + "required_fire_flow": "Portata richiesta", + "review_overdue": "La data di revisione del piano preventivo è scaduta.", + "secondary_contact": "Contatto secondario", + "secondary_contact_phone": "Telefono contatto secondario", + "tactical_summary": "Riepilogo tattico", + "utilities": "Interruzione utenze", + "utility_notes": "Note sulle utenze", + "water_shutoff": "Chiusura acqua", + "water_supply": "Approvvigionamento idrico", + "water_supply_notes": "Note sull'approvvigionamento idrico" + }, "public": "Pubblico", "required": "Obbligatorio", "save": "Salva contatto", @@ -569,7 +827,9 @@ "systemInformation": "Informazioni sistema", "tabs": { "details": "Dettagli", - "notes": "Note" + "files": "File", + "notes": "Note", + "preplan": "Piano preventivo" }, "threads": "Threads", "title": "Contatti", @@ -578,7 +838,13 @@ "unknown_person": "Persona sconosciuta", "visibility": "Visibilità", "website": "Sito web", - "zip": "CAP" + "zip": "CAP", + "withheld": "{{count}} dettagli protetti sono nascosti.", + "categoryLabel": "Categoria: {{name}}", + "physicalAddress": "Indirizzo fisico", + "mailingAddress": "Indirizzo postale", + "hasGeofence": "Questo luogo ha un geofence mappato.", + "customFields": "Campi personalizzati" }, "data_protection": { "conceal": "Nascondi di nuovo", @@ -813,11 +1079,193 @@ "select_all": "Seleziona tutto", "selected_count": "{{count}} selezionate", "title": "Notifiche", - "unable_to_load": "Impossibile caricare le notifiche" + "unable_to_load": "Impossibile caricare le notifiche", + "detail_title": "Notifica", + "additional_info": "Informazioni aggiuntive", + "view_call": "Visualizza chiamata", + "view_chat": "Visualizza chat", + "open_reference": "Apri" }, "onboarding": { "message": "Benvenuto nell'app Resgrid Unit" }, + "operations": { + "all": "Tutti", + "back": "Indietro", + "disabled": "I dispiegamenti non sono abilitati per questo dipartimento.", + "entryType": { + "0": "Dispiegamento", + "1": "Attesa", + "2": "Viaggio" + }, + "errors": { + "denied": "Non hai accesso a questo dispiegamento.", + "disabled": "Questa funzione è disattivata per il tuo dipartimento.", + "locked": "Sblocca i dati protetti per continuare.", + "retry": "Impossibile raggiungere il server. Riprova.", + "validation": "Il server ha rifiutato la modifica.", + "timereports_subject_covered": "Questo equipaggio o questa persona ha già ore su un altro rapporto di questo giorno.", + "timereports_date_exists": "Esiste già un rapporto per questo equipaggio o persona in quel giorno.", + "timereports_scope_invalid": "Questo equipaggio o questa persona non è attivo in questo dispiegamento.", + "timereports_locked": "Questo rapporto non può più essere modificato.", + "timereports_time_invalid": "Inserisci gli orari come HH:mm.", + "deployments_closed": "Questo dispiegamento è chiuso.", + "expenses_receipt_invalid": "Impossibile leggere la ricevuta.", + "deployments_attachment_too_large": "La ricevuta è troppo grande." + }, + "financeMode": { + "0": "Operativo", + "1": "Recupero costi", + "2": "Fatturabile" + }, + "issues": { + "end_before_start": "L'ora di fine deve essere successiva a quella di inizio.", + "missing_subject": "Scegli a chi o a cosa si riferisce questa voce.", + "missing_time": "Inserisci un'ora di inizio e di fine.", + "overlap": "Questa voce si sovrappone a un'altra per la stessa persona o unità.", + "subject_outside_report_scope": "Questa voce riguarda qualcuno al di fuori dell'equipaggio o della persona di questo rapporto.", + "subject_on_other_report": "Questa persona o unità ha già ore su un altro rapporto dello stesso giorno.", + "subject_not_on_roster": "Questa voce riguarda qualcuno che non è nell'organico.", + "entries_overlap": "Due voci della stessa persona o unità si sovrappongono.", + "no_entries": "Aggiungi almeno una voce.", + "break_rule": "Un turno lungo non ha pause registrate.", + "long_travel": "Più viaggio di quanto consentito in un giorno; serve approvazione.", + "outside_report_date": "Questa voce è fuori dalla data del rapporto." + }, + "mars": { + "counts": "{{errors}} errori · {{warnings}} avvisi", + "draft": "Prepara la bozza F-42 dai rapporti orari", + "f42": "F-42", + "help": "Il F-42 viene preparato dai rapporti orari inviati e verificato con le regole del portale CAL OES. La presentazione resta un passaggio manuale e tracciato sul web.", + "none": "Nessun elemento MARS per questo dispiegamento.", + "notReady": "Non pronto per il portale", + "ready": "Pronto per il portale", + "record": "MARS {{id}} · {{status}}", + "title": "F-42", + "validate": "Convalida" + }, + "noNumbers": "Nessun numero di incidente o richiesta", + "none": "Nessun dispiegamento.", + "open": "Aperti", + "phase": { + "0": "Mobilitazione", + "1": "Attesa", + "2": "Incidente", + "3": "Rientro" + }, + "reportStatus": { + "0": "Bozza", + "1": "Inviato", + "2": "Approvato", + "3": "Fatturato", + "4": "Annullato" + }, + "roster": "{{units}} unità · {{personnel}} persone", + "status": { + "0": "Pianificato", + "1": "Attesa", + "2": "Attivo", + "3": "Smobilitazione", + "4": "Completato", + "5": "Annullato" + }, + "time": { + "accommodation": "Alloggio fornito dall'agenzia", + "addEntry": "Aggiungi voce", + "from": "Da", + "hours": "{{hours}} h", + "meals": "Pasti forniti dall'agenzia", + "noEntries": "Nessuna voce.", + "noReport": "Nessun rapporto orario per questo giorno.", + "notes": "Note", + "remove": "Rimuovi", + "report": "Rapporto #{{number}}", + "save": "Salva voci", + "start": "Avvia rapporto orario", + "subject": "Chi o cosa", + "submit": "Invia rapporto", + "title": "Tempo", + "to": "A", + "total": "Totale {{hours}} h", + "type": "Tipo di voce", + "unpaidBreak": "Pausa non retribuita (min)", + "crewReport": "Rapporto ore dell'equipaggio #{{number}} · {{name}}", + "individualReport": "Rapporto ore #{{number}} · {{name}}", + "noCrewReport": "Nessun rapporto dell'equipaggio per {{name}} in questo giorno.", + "coveredBy": "Queste ore sono già nel rapporto #{{number}} di questo giorno.", + "startCrew": "Avvia rapporto dell'equipaggio", + "readOnly": "Puoi vedere questo rapporto ma non modificarlo.", + "nextDay": "Termina il giorno dopo", + "applyToCrew": "Applica all'equipaggio", + "approve": "Approva rapporto", + "dayReports": "Rapporti di questo giorno", + "awaitingApproval": "In attesa di approvazione ({{count}})", + "notOnRoster": "Non sei nell'organico né in un equipaggio di questo dispiegamento." + }, + "title": "Dispiegamenti", + "usage": { + "add": "Aggiungi lettura", + "endOdometer": "Contachilometri finale", + "engineHours": "Ore motore", + "fuel": "Carburante", + "needsReview": "Da verificare", + "phase": "Fase", + "saved": "Lettura salvata.", + "startOdometer": "Contachilometri iniziale", + "title": "Utilizzo", + "unit": "Unità" + }, + "crewOf": "Equipaggio di {{names}}", + "previousDay": "Giorno precedente", + "nextDay": "Giorno successivo", + "scope": { + "deployment": "Intero dispiegamento", + "mine": "Il mio tempo", + "crew": "Equipaggio: {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "Firme", + "crewBossSigned": "Firmato dal capo equipaggio {{when}}", + "crewBossPending": "Non ancora firmato dal capo equipaggio.", + "customerSigned": "Firmatario dell'ente: {{name}}", + "customerPending": "Nessuna firma dell'ente registrata.", + "asCrewBoss": "Firma come capo equipaggio", + "customerName": "Nome del firmatario dell'ente", + "recordCustomer": "Registra la firma dell'ente" + }, + "expenses": { + "title": "Spese", + "type": { + "0": "Diaria pasti", + "1": "Alloggio", + "2": "Alloggio privato", + "3": "Traghetto", + "4": "Carburante", + "5": "Reintegro materiali", + "6": "Altro" + }, + "typeLabel": "Tipo di spesa", + "amount": "Importo ({{currency}})", + "description": "Descrizione", + "city": "Città", + "takePhoto": "Scatta foto", + "choosePhoto": "Scegli foto", + "receiptAttached": "Foto della ricevuta allegata.", + "save": "Aggiungi spesa", + "add": "Nuova spesa", + "noneToday": "Nessuna spesa in questo giorno.", + "otherDays": "Altri giorni", + "hasReceipt": "ricevuta", + "remove": "Rimuovi", + "removeTitle": "Rimuovere la spesa?", + "removeBody": "La spesa e la sua ricevuta vengono rimosse dal dispiegamento.", + "cancel": "Annulla", + "saved": "Spesa salvata.", + "amountInvalid": "Inserisci un importo come 42.50.", + "photoDenied": "Consenti l'accesso alla fotocamera o alle foto per allegare una ricevuta." + } + }, "protocols": { "details": { "close": "Chiudi", @@ -846,6 +1294,172 @@ "unknown_type_warning": "Questo tipo di notifica non è riconosciuto. Contattare l'assistenza se il messaggio continua ad apparire.", "view_call": "Visualizza chiamata" }, + "records": { + "add_row": "Aggiungi riga", + "agencies": "Agenzie", + "agreement": "Accordo", + "assigned": "Assegnati a te", + "attachment_failed": "Non è stato possibile inviare l'allegato.", + "attachment_file_missing": "Il file non è più su questo dispositivo.", + "attachment_unreadable": "Non è stato possibile leggere il file.", + "attachment_will_resume": "In pausa. Riprenderà da dove si è fermato.", + "attachments": "Allegati", + "attachments_hint": "Gli allegati si aggiungono dopo il salvataggio del record.", + "attestation": "Attesto che questo record è completo e accurato", + "back": "Indietro", + "busy": "Una lettura è già in corso", + "camera_denied": "La fotocamera non è disponibile senza autorizzazione.", + "cancel": "Annulla", + "captured_on": "Acquisito", + "choose": "Scegli…", + "choose_file": "Scegli un file", + "choose_photo": "Scegli una foto", + "closed_out_on": "Chiuso il", + "closeout_notes": "Note di chiusura", + "conflict_app_version": "I record non sono abilitati per questa versione dell'app.", + "conflict_definition_retired": "Questo modulo è stato ritirato. Riavvialo dall'elenco attuale.", + "conflict_etag": "Qualcun altro ha modificato questo record. Ricaricalo e riapplica le tue modifiche.", + "conflict_permission": "Non hai più accesso a questo record.", + "conflict_protected_data": "Questo modulo richiede una connessione attiva e il tuo accesso ai dati protetti.", + "conflict_scope": "Il tuo accesso è cambiato, quindi il record è stato ricaricato.", + "connector_disabled": "Disattivato", + "connector_maintained": "Mantenuto da un connettore", + "connector_not_ready": "Non pronto", + "connector_ready": "Pronto", + "connectors": "Connettori esterni", + "connectors_hint": "Ogni connettore legge un sistema di ordinazione secondo le sue condizioni, con una credenziale cifrata e un limite di richieste. L'autorità di scrittura è rifiutata.", + "connectors_home_hint": "Feed dei sistemi di ordinazione e cosa resta da riconciliare", + "cost_code": "Codice di costo", + "deployment_status_closed_out": "Chiuso", + "deployment_status_mobilized": "Mobilitato", + "deployment_status_open": "Aperto", + "deployment_status_released": "Rilasciato", + "deployments": "Missioni", + "deployments_hint": "Il sistema di ordinazione resta autorevole. Le assegnazioni vengono aggiornate da un coordinatore sul web; qui non se ne modifica nessuna.", + "deployments_home_hint": "Ordini di risorse esterni che il tuo dipartimento copre", + "drafts": "Bozze", + "field_required": "Questo campo è obbligatorio.", + "fill_status_accepted": "Accettato", + "fill_status_assigned": "Assegnato", + "fill_status_checked_in": "Registrato", + "fill_status_declined": "Rifiutato", + "fill_status_demobilized": "Smobilitato", + "fill_status_mobilized": "Mobilitato", + "fill_status_released": "Rilasciato", + "fill_status_requested": "Richiesto", + "fill_status_returned": "Rientrato", + "fills": "Richieste e assegnazioni", + "finalize": "Finalizza", + "finalized": "Finalizzato.", + "fix_fields": "Alcuni campi richiedono ancora attenzione.", + "forbidden": "Solo amministratori del dipartimento", + "granted": "Concessa", + "hide_closed": "Nascondi missioni chiuse", + "include_closed": "Mostra missioni chiuse", + "last_error": "Ultimo errore", + "last_read": "Ultima lettura", + "last_success": "Ultimo successo", + "load_failed": "Non è stato possibile caricare il record.", + "loading": "Caricamento record…", + "location_kept": "Posizione mantenuta", + "location_removed": "Posizione rimossa", + "manage_on_web": "I connettori vengono creati, modificati e dotati di credenziali sul web.", + "minutes": "{{count}} min", + "needed_on": "Necessario", + "needs_attention": "Richiede la tua attenzione", + "never_read": "Mai letto", + "new_record": "Nuovo record", + "no_assignments": "Non ti è assegnato nulla.", + "no_attachments": "Ancora nessun allegato.", + "no_connectors": "Nessun connettore", + "no_deployments": "Nessuna missione", + "no_drafts": "Non hai bozze.", + "no_fills": "Nessuna richiesta su questo ordine", + "no_recent": "Nessun record recente.", + "no_reconciliation": "Nessuna discrepanza tra la fonte e i tuoi registri", + "no_returned": "Non ti è stato rinviato nulla.", + "no_rows": "Ancora nessuna riga.", + "no_runs": "Nessuna esecuzione ancora", + "not_found": "Quel record non è disponibile.", + "not_granted": "Non concessa", + "not_sent_yet": "In attesa di invio", + "nothing_to_create": "Al momento non si può avviare nulla da qui.", + "offices": "Uffici", + "offline_capable": "Funziona offline", + "online_only": "Questo modulo si compila solo online.", + "online_only_badge": "Solo online", + "open_artifact": "Apri artefatto di origine", + "open_record": "Apri registro", + "pending": "Non inviato", + "poll_interval": "Intervallo di polling", + "preview": "Anteprima", + "purpose_acknowledge": "Conferma questo record", + "purpose_attach": "Aggiungi un allegato", + "purpose_complete": "Completa questo record", + "purpose_correct": "Correggi questo record", + "purpose_review": "Revisiona questo record", + "read_authority": "Autorità di lettura", + "read_feed_now": "Leggi il feed ora", + "reading_feed": "Lettura…", + "reason_app_disabled": "I record non sono attivi per questa app nel tuo dipartimento.", + "reason_app_version_too_old": "Aggiorna l'app per usare i record.", + "reason_module_disabled": "I record non sono disponibili per il tuo dipartimento.", + "reason_not_member": "Non sei un membro attivo di questo dipartimento.", + "reason_records_not_usable": "Il tuo dipartimento non ha ancora attivato i record.", + "recent": "Recenti", + "reconciliation": "Riconciliazione", + "reconciliation_count": "{{count}} da riconciliare", + "reconciliation_hint": "Dove la fonte e il tuo registro non concordano. Nulla viene applicato automaticamente; decide un coordinatore sul web.", + "reconciliation_local_fill_missing_in_source": "L'assegnazione esiste in locale ma non nella fonte", + "reconciliation_source_closed_local_open": "La fonte ha chiuso l'ordine, ma ci sono assegnazioni ancora aperte", + "reconciliation_source_released_local_not_returned": "La fonte ha rilasciato l'ordine, ma un'assegnazione non è ancora rientrata", + "reconciliation_source_request_missing_locally": "La richiesta esiste nella fonte ma non in locale", + "reconciliation_source_status_ahead": "Lo stato della fonte è più avanti dell'assegnazione locale", + "reconciliation_source_status_behind": "Lo stato della fonte è indietro rispetto all'assegnazione locale", + "refresh_catalog": "Aggiorna", + "requests_this_hour": "Richieste in quest'ora", + "restricted": "Riservato", + "returned": "Rinviati a te", + "run_counts": "Visti {{seen}} · creati {{created}} · istantanee {{snapshots}} · richieste {{requests}} · invariati {{unchanged}} · rifiutati {{rejected}} · da riconciliare {{conflicts}}", + "run_failed": "Impossibile leggere il feed", + "run_finished": "Feed letto {{outcome}}: {{created}} creati, {{snapshots}} istantanee, {{requests}} richieste, {{conflicts}} da riconciliare", + "run_log": "Registro delle esecuzioni", + "run_not_ok": "La lettura del feed è terminata con {{outcome}}", + "run_outcome_disabled": "Disattivato", + "run_outcome_failed": "Fallito", + "run_outcome_ok": "OK", + "run_outcome_rate_limited": "Limite di richieste", + "run_outcome_rejected": "Rifiutato", + "run_trigger_inbound": "Inviato dalla fonte", + "run_trigger_manual": "Manuale", + "run_trigger_poll": "Programmato", + "save": "Salva", + "save_and_send": "Salva e invia", + "save_failed": "Non è stato possibile salvare.", + "save_locally": "Mantieni su questo dispositivo", + "saved": "Salvato.", + "saved_locally": "Mantenuto su questo dispositivo. Verrà inviato quando tornerai online.", + "sign_statement": "Firmo questo record come autore", + "source": "Fonte", + "source_says": "Fonte:", + "submit_for_review": "Invia per revisione", + "submitted": "Inviato per revisione.", + "take_photo": "Scatta una foto", + "terms": "Condizioni della fonte", + "terms_acknowledged": "Confermate", + "terms_not_acknowledged": "Non ancora confermate", + "title": "Record", + "trainee": "Tirocinante", + "unavailable": "I record non sono al momento disponibili in questa app.", + "unit_code": "Unità", + "unsupported_definition": "Questo modulo usa un campo che questa versione dell'app non può compilare. Aggiorna l'app o usa l'app web.", + "unsupported_field": "Questa app non può ancora mostrare questo campo. Usa l'app web per compilarlo.", + "untitled": "Senza titolo", + "update_required": "Aggiornamento dell'app necessario", + "write_authority": "Autorità di scrittura", + "write_refused": "Rifiutata: nulla viene riscritto", + "your_record_says": "Il tuo registro:" + }, "roles": { "assignedElsewhere": "In un altro ruolo", "clearAssignment": "Rimuovi l'assegnazione attuale", @@ -1117,6 +1731,17 @@ "version": "Versione", "website": "Sito web" }, + "site_info": { + "additional_contact": "Contatto aggiuntivo", + "alert_notes": "Note di allerta", + "empty": "Nessuna informazione sul sito", + "empty_description": "Nessun contatto collegato a questa chiamata ha un piano preventivo, pericoli, note di allerta o file.", + "entrance": "Ingresso", + "files": "File del sito", + "load_failed": "Impossibile caricare le informazioni sul sito.", + "loading": "Caricamento informazioni sul sito...", + "primary_contact": "Contatto principale" + }, "status": { "add_note": "Aggiungi nota", "both_destinations_enabled": "Può rispondere a chiamate o stazioni", @@ -1157,6 +1782,7 @@ "map": "Mappa", "notes": "Note", "protocols": "Protocolli", + "records": "Record", "routes": "Percorsi", "settings": "Impostazioni", "weather_alerts": "Meteo" @@ -1277,5 +1903,80 @@ "unknown": "Sconosciuto" } }, - "welcome": "Benvenuto nell'app Resgrid Unit" + "welcome": "Benvenuto nell'app Resgrid Unit", + "inventory": { + "title": "Inventario", + "titleFor": "Inventario · {{name}}", + "selectUnit": "Seleziona prima il tuo mezzo.", + "unnamedLocation": "Vano", + "unnamedItem": "Articolo", + "countName": "Controllo {{name}} {{date}}", + "notMigrated": "L'inventario è in fase di configurazione per la tua organizzazione.", + "disabled": "L'inventario è disattivato per la tua organizzazione.", + "noLocations": "A questo mezzo non è assegnato alcun inventario.", + "startCount": "Conta l'inventario", + "wholeUnit": "Intero mezzo: {{name}}", + "pickLocation": "Dove contare", + "startHelp": "Conta ciò che è davvero sul mezzo. Le differenze vengono registrate al completamento.", + "start": "Avvia conteggio", + "cannotCount": "Per contare serve il permesso di rettifica dell'inventario su questo mezzo.", + "counts": "Conteggi recenti", + "count": "Conteggio inventario", + "empty": "Qui non c'è niente.", + "serial": "Matricola {{serial}}", + "quantity": "× {{quantity}}", + "back": "Indietro", + "completeTitle": "Completare il conteggio?", + "completeBody": "{{count}} articoli differiscono dal previsto. Completando si registrano le differenze.", + "completeNoChanges": "Tutto corrisponde al previsto.", + "cancel": "Annulla", + "complete": "Completa", + "cancelTitle": "Annullare questo conteggio?", + "cancelBody": "Nulla di quanto contato verrà registrato.", + "keep": "Continua a contare", + "cancelCount": "Annulla conteggio", + "progress": "{{counted}} di {{total}} contati", + "differences": "{{count}} differenze", + "awaitingWitness": "È cambiato un articolo controllato; una seconda persona deve attestare il conteggio sul web.", + "completed": "Conteggio completato.", + "expected": "Previsto {{quantity}} {{unit}}", + "present": "Presente", + "missing": "Mancante", + "less": "Uno in meno", + "more": "Uno in più", + "matches": "Corrisponde", + "save": "Salva avanzamento", + "countStatus": { + "0": "In corso", + "1": "In attesa di testimone", + "2": "Completato", + "3": "Annullato" + }, + "assetStatus": { + "0": "In servizio", + "1": "Consegnato", + "2": "In riparazione", + "3": "Danneggiato", + "4": "Perso", + "5": "Consumato", + "6": "Dismesso" + }, + "errors": { + "retry": "Impossibile raggiungere il server. Riprova.", + "locked": "Sblocca i dati protetti per continuare.", + "PermissionRequired": "Non hai il permesso per farlo.", + "MembershipRequired": "Non sei un membro attivo di questa organizzazione.", + "InventoryDisabled": "L'inventario è disattivato per la tua organizzazione.", + "MigrationRequired": "L'inventario è in fase di configurazione per la tua organizzazione.", + "CountScopeTooLarge": "Troppi articoli per un solo conteggio. Conta un vano alla volta.", + "CountEmpty": "Non c'è nulla da contare qui.", + "CountSnapshotChanged": "L'inventario del mezzo è cambiato durante il conteggio. Annulla e ricomincia.", + "CountStateConflict": "Il conteggio è cambiato su un altro dispositivo. Riaprilo e riprova.", + "ReturnAssetFirst": "Un articolo segnato come mancante è ancora consegnato a qualcuno. Restituiscilo prima sul web.", + "ProtectedDataRequired": "Sblocca i dati protetti per continuare.", + "CountAlreadyExists": "Questo conteggio esiste già.", + "LocationUnavailable": "Quella posizione non è disponibile.", + "InvalidCount": "Controlla i valori contati e riprova." + } + } } diff --git a/src/translations/pl.json b/src/translations/pl.json index f35bf69c..716b8fbe 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -126,6 +126,12 @@ "title": "Notatki zgłoszenia" }, "call_detail": { + "activity_link": { + "auto": "Powiązano automatycznie", + "auto_hint": "Wysłano bez zgłoszenia; Resgrid powiązał go na podstawie poprzedniego statusu lub zadysponowania.", + "inferred": "Wywnioskowano", + "inferred_hint": "Ustawiono bez celu przez jednostkę lub osobę zadysponowaną do tego zgłoszenia podczas pracy przy nim." + }, "address": "Adres", "call_location": "Lokalizacja zgłoszenia", "close_call": "Zamknij zgłoszenie", @@ -201,6 +207,7 @@ "dispatched": "Wysłane", "info": "Info", "protocols": "Protokoły", + "site": "Info o obiekcie", "timeline": "Aktywność" }, "timestamp": "Znacznik czasu", @@ -228,6 +235,9 @@ "contact_name_placeholder": "Wpisz nazwę kontaktu", "contact_phone": "Telefon kontaktowy", "contact_phone_placeholder": "Wpisz telefon kontaktowy", + "contact_picker": { + "linked": "Powiązany kontakt: {{name}}" + }, "coordinates": "Współrzędne GPS", "coordinates_found": "Współrzędne znalezione i adres zaktualizowany", "coordinates_geocoding_error": "Nie udało się pobrać adresu dla współrzędnych, ale lokalizacja ustawiona na mapie", @@ -445,6 +455,206 @@ "type_sector_rotation": "Rotacja sektorów", "type_unit": "Jednostka" }, + "checklists": { + "errors": { + "conflict": "Ta kontrola zmieniła się na serwerze. Praca lokalna została zachowana; sprawdź ją przed ponowieniem.", + "denied": "Dostęp jest niedostępny. Zaszyfrowana praca lokalna została zachowana.", + "locked": "Odblokuj chronione dane, aby kontynuować.", + "offline": "Brak połączenia: wyświetlane są bezpiecznie zapisane kontrole.", + "retry": "Nie udało się zakończyć synchronizacji. Praca lokalna została zachowana.", + "storage_full": "Osiągnięto lokalny limit wersji roboczych lub dowodów.", + "validation": "Sprawdź wymagane odpowiedzi, daty, notatki i dowody, a następnie spróbuj ponownie." + }, + "labels": { + "Actions": "Działania", + "Active": "Aktywna", + "AnchorMonth": "Miesiąc odniesienia", + "Annual": "Co rok", + "Answer": "Odpowiedź", + "Answered": "Udzielono odpowiedzi", + "AssetUnavailable": "Sprzęt z numerem seryjnym jest niedostępny.", + "Assignment": "Przypisano do", + "AssignmentAutomatic": "Automatyczne przypisanie", + "AssignmentPermission": "Nie przypisano Ci wykonania tej kontroli.", + "AssignmentType1": "Osoba", + "AssignmentType2": "Rola personelu", + "AssignmentType3": "Grupa lub stacja", + "AssignmentType4": "Załoga jednostki", + "AssignmentUnavailable": "Wybierz dostępne przypisanie w tej organizacji.", + "Attest": "Potwierdź i zakończ", + "Attestation": "Oświadczenie o niezależnej weryfikacji", + "Author": "Autor", + "AwaitingWitness": "Oczekiwanie na świadka", + "Back": "Wstecz", + "CalendarInclude": "Kontrole gotowości", + "CalendarRange": "Wybierz zakres kalendarza nieprzekraczający 93 dni.", + "Cancelled": "Anulowana", + "Category": "Kategoria", + "Checkbox": "Pole wyboru", + "Checked": "Zaznaczone", + "ChecklistCompleted": "Lista kontrolna ukończona", + "ChecklistFailed": "Lista kontrolna niezaliczona", + "ChecklistMissed": "Lista kontrolna niewykonana", + "ChecklistOccurrenceSkipped": "Pominięcie zaplanowanej kontroli usprawiedliwione", + "ChecklistScheduleChanged": "Harmonogram kontroli zmieniony", + "Checklists": "Listy kontrolne", + "Checks": "Kontrole", + "Choose": "Wybierz", + "Critical": "Krytyczne", + "Daily": "Codziennie", + "Date": "Data", + "DayOfMonth": "Dzień miesiąca", + "DeleteDraft": "Usuń wersję roboczą", + "Department": "Jednostka organizacyjna", + "Draft": "Wersja robocza", + "DraftGuidance": "Zapisz wersję roboczą, a następnie ją opublikuj. Istniejące wykonania zachowują pierwotną wersję. Nie wprowadzaj danych pacjentów.", + "DueChecks": "Kontrole do wykonania", + "DueGuidance": "Nadchodzące kontrole na siedem dni i kontrole nieukończone. Zaległe kontrole można nadal wykonać.", + "EditChecklist": "Edytor list", + "EditDraft": "Edytuj wersję roboczą", + "EditSchedule": "Edytuj harmonogram", + "Evidence": "Dowód", + "ExcuseCheck": "Usprawiedliw pominięcie", + "Export": "Eksportuj JSON", + "Facility": "Obiekt", + "Fail": "Nie zaliczono", + "FailNote": "Nieudana kontrola wymaga notatki.", + "Failed": "Nie zaliczono", + "Free": "Listy są bezpłatne dla wszystkich jednostek.", + "Frequency": "Częstotliwość", + "Guidance": "Przed użyciem dostosuj szablony do sprzętu, procedur producenta i lokalnych wymagań.", + "History": "Historia", + "InProgress": "W trakcie", + "Incomplete": "Niekompletne", + "InventoryAsset": "Sprzęt z numerem seryjnym", + "Item": "Element", + "Maintenance": "Konserwacja i zlecenia pracy (Readiness Pro)", + "Missed": "Niewykonana", + "MonthGuidance": "Dla kontroli miesięcznych lub rzadszych. W krótszych miesiącach używany jest ostatni dzień. Miesiąc odniesienia wyznacza kontrole kwartalne, półroczne i roczne.", + "Monthly": "Co miesiąc", + "Name": "Nazwa", + "NewChecklist": "Nowa lista", + "NewSchedule": "Nowy harmonogram", + "Next": "Następna", + "No": "Nie", + "NoDefinitions": "Brak list na tej stronie.", + "NoDueChecks": "Brak kontroli na tej liście.", + "NoResults": "Brak pasujących szablonów.", + "NoSchedules": "Nie ma jeszcze harmonogramów.", + "NoScore": "Brak punktacji", + "Note": "Notatka", + "OccurrenceUnavailable": "Ta kontrola jest niedostępna lub została już zmieniona.", + "OpenCheck": "Otwórz kontrolę", + "Optional": "Opcjonalne", + "Other": "Inne", + "Pass": "Zaliczono", + "Passed": "Zaliczono", + "PerShift": "Na każdej zmianie", + "Personnel": "Personel", + "Photo": "Zdjęcie", + "Preview": "Podgląd szablonu", + "Previous": "Poprzednia", + "Print": "Drukuj", + "PublishDraft": "Opublikuj wersję roboczą", + "PublishGuidance": "Publikacja tworzy niezmienną wersję dla nowych wykonań.", + "Published": "Opublikowana", + "PublishedVersion": "Opublikowana wersja", + "Quantity": "Ilość", + "Quarterly": "Co kwartał", + "ReminderBefore": "Minuty przed terminem (0 wyłącza)", + "ReminderDigest": "Łącz oczekujące przypomnienia w podsumowania", + "ReminderEnabled": "Włącz przypomnienia o listach kontrolnych", + "ReminderEscalate": "Powiadom administratorów działu po tylu minutach opóźnienia (puste pole wyłącza)", + "ReminderFixedTime": "Godzina codziennego podsumowania (strefa czasowa organizacji; zastępuje początek zmiany)", + "ReminderHelp": "Przypomnienia dotyczą kontroli rozpoczynających się po ich włączeniu. Wiadomości zawierają odnośnik do logowania i uwzględniają preferencje powiadomień każdej osoby. Podsumowania łączą oczekujące przypomnienia według odbiorcy.", + "ReminderMessage": "Dostępne są aktualizacje list kontrolnych. Zaloguj się, aby przejrzeć swoje kontrole.", + "ReminderMissed": "Powiadamiaj odpowiedzialny personel o pominiętych kontrolach", + "ReminderSettings": "Przypomnienia o listach kontrolnych", + "ReminderShiftStart": "Wyślij podsumowanie na początku zmiany", + "ReminderTimeValidation": "Wprowadź jedną godzinę w formacie HH:mm.", + "ReminderTitle": "Przypomnienia o listach kontrolnych", + "ReminderValidation": "Podaj czas przypomnienia od 0 do 10080 minut oraz czas powiadomienia administratorów od 1 do 43200 minut lub pozostaw ten drugi pusty.", + "Remove": "Usuń", + "ReportedLocation": "Podana lokalizacja", + "Required": "Wymagane", + "Result": "Wynik", + "Retire": "Wycofaj", + "Retired": "Wycofana", + "RunGuidance": "Odpowiedz na każde mające zastosowanie pytanie. Nie dotyczy wymaga uzasadnienia. Zapisz postęp, aby wrócić później. Przesłanych odpowiedzi i dowodów nie można zmieniać.", + "SaveDraft": "Zapisz wersję roboczą", + "SaveProgress": "Zapisz postęp", + "SaveReminders": "Zapisz ustawienia przypomnień", + "SaveSchedule": "Zapisz harmonogram", + "ScheduleEnabled": "Harmonogram włączony", + "ScheduleEndDate": "Data zakończenia (opcjonalna)", + "ScheduleNotes": "Uwagi do harmonogramu", + "SchedulePaused": "Wstrzymany", + "SchedulePermission": "Nie możesz zarządzać harmonogramami kontroli.", + "ScheduleRequiresPublished": "Opublikuj listę kontrolną przed jej zaplanowaniem.", + "ScheduleStartDate": "Data rozpoczęcia", + "ScheduleTimeGuidance": "Godziny dotyczą tej strefy. Pominięta godzina jest przesuwana do przodu; powtórzona godzina występuje tylko raz.", + "ScheduleUnavailable": "Ten harmonogram jest niedostępny.", + "ScheduleValidation": "Sprawdź daty, godziny, obiekt i powtarzalność harmonogramu.", + "ScheduleVersionGuidance": "Zapis przypina aktualną opublikowaną wersję. Zmiany zastępują przyszłe nierozpoczęte kontrole; wstrzymanie anuluje nierozpoczęte kontrole.", + "ScheduleWorkshift": "Zmiana dla kontroli na każdej zmianie", + "Scheduled": "Zaplanowana", + "ScheduledFor": "Zaplanowano na", + "Schedules": "Harmonogramy", + "Score": "Punktacja", + "Search": "Szukaj", + "SearchLabel": "Znajdź szablon", + "SelectTarget": "Wybierz cel", + "SemiAnnual": "Co sześć miesięcy", + "Signature": "Podpis", + "SkipReason": "Powód usprawiedliwionego pominięcia", + "SkipReasonRequired": "Podaj powód zawierający do 4000 znaków.", + "Skipped": "Pominięcie usprawiedliwione", + "StartRun": "Rozpocznij listę", + "Started": "Rozpoczęto", + "State": "Stan", + "SubmitRun": "Prześlij listę", + "Submitted": "Przesłano", + "Target": "Cel", + "Templates": "Szablony list", + "TimeZone": "Strefa czasowa", + "TimesOfDay": "Godziny lokalne", + "TimesOfDayGuidance": "Podaj do ośmiu godzin w formacie 24-godzinnym, oddzielonych przecinkami.", + "Unanswered": "Bez odpowiedzi", + "Unchecked": "Niezaznaczone", + "Unit": "Jednostka", + "Units": "Jednostki", + "Version": "Wersja", + "Weekly": "Co tydzień", + "WeeklyDays": "Dni kontroli cotygodniowych", + "WindowEnds": "Koniec terminu", + "WindowMinutes": "Czas na wykonanie (minuty)", + "Witness": "Uwierzytelniony autor i inny uwierzytelniony członek muszą niezależnie zweryfikować wynik.", + "WitnessInstructions": "Inny uwierzytelniony członek musi niezależnie sprawdzić przesłane odpowiedzi i dowody. Udostępnij mu link do tej strony.", + "WitnessLabel": "Świadek", + "WorkshiftFallback": "Używaj godzin lokalnych codziennie", + "WorkshiftGuidance": "Tylko dla kontroli na każdej zmianie. Wybrana zmiana używa zapisanych dat rozpoczęcia; w przeciwnym razie godziny lokalne obowiązują codziennie.", + "Yes": "Tak" + }, + "ui": { + "calendar": "Kalendarz", + "clearSignature": "Wyczyść podpis", + "close": "Zamknij", + "confirm": "Zastąp", + "editLocal": "Edytuj lokalną wersję roboczą", + "localDrafts": "Lokalne wersje robocze", + "notApplicable": "Nie dotyczy", + "prepareOffline": "Przygotuj do pracy bez połączenia", + "queued": "Zapisano bezpiecznie na urządzeniu; oczekiwanie na synchronizację.", + "reason": "Powód", + "refresh": "Odśwież", + "removeImage": "Usuń obraz", + "replaceLocal": "Zastąpić lokalne odpowiedzi i oczekujące obrazy wersją z serwera?", + "retry": "Ponów synchronizację", + "takePhoto": "Zrób zdjęcie", + "unlock": "Odblokuj chronione dane", + "useServer": "Użyj wersji z serwera" + } + }, "common": { "add": "Dodaj", "back": "Wstecz", @@ -536,6 +746,12 @@ "expires": "Wygasa", "facebook": "Facebook", "faxPhone": "Faks", + "files": { + "download_failed": "Nie udało się pobrać pliku.", + "empty": "Do tego kontaktu nie dołączono żadnych plików.", + "loading": "Ładowanie plików...", + "share_unavailable": "Udostępnianie nie jest dostępne na tym urządzeniu." + }, "formError": "Napraw błędy w formularzu", "homePhone": "Telefon domowy", "identification": "Identyfikacja", @@ -557,6 +773,48 @@ "otherInfo": "Inne informacje", "person": "Osoba", "phone": "Telefon", + "preplan": { + "access": "Dostęp", + "access_notes": "Uwagi dotyczące dostępu", + "alarm_company": "Firma alarmowa", + "alarm_company_phone": "Telefon firmy alarmowej", + "alarm_panel": "Centrala alarmowa", + "alert_flag": "Alarm", + "construction": "Konstrukcja · dach · użytkowanie", + "electric_shutoff": "Wyłącznik prądu", + "emergency_contact": "Kontakt alarmowy", + "emergency_contact_phone": "Telefon kontaktu alarmowego", + "gas_shutoff": "Zawór gazu", + "gate_code": "Kod bramy", + "general_hazard_notes": "Ogólne uwagi o zagrożeniach", + "general_hazards": "Zagrożenia i taktyka", + "hazards": "Zagrożenia obiektu", + "hazmat_on_site": "Materiały niebezpieczne na miejscu", + "knox_box": "Skrytka na klucze", + "last_reviewed": "Ostatni przegląd", + "loading": "Ładowanie planu operacyjnego...", + "location": "Lokalizacja", + "nearest_hydrant": "Najbliższy hydrant", + "never_reviewed": "Nigdy nie przeglądano", + "next_review": "Następny przegląd", + "none": "Dla tego kontaktu nie zapisano planu operacyjnego.", + "occupancy": "Konstrukcja i użytkowanie", + "occupancy_hours": "Godziny użytkowania", + "occupancy_notes": "Uwagi o użytkowaniu", + "occupant_load": "Liczba osób", + "occupants_needing_assistance": "Osoby mogą potrzebować pomocy przy ewakuacji", + "on_site_contacts": "Kontakty na miejscu", + "required_fire_flow": "Wymagana wydajność", + "review_overdue": "Termin przeglądu planu operacyjnego minął.", + "secondary_contact": "Kontakt dodatkowy", + "secondary_contact_phone": "Telefon kontaktu dodatkowego", + "tactical_summary": "Podsumowanie taktyczne", + "utilities": "Odcięcia mediów", + "utility_notes": "Uwagi o mediach", + "water_shutoff": "Zawór wody", + "water_supply": "Zaopatrzenie w wodę", + "water_supply_notes": "Uwagi o zaopatrzeniu w wodę" + }, "public": "Publiczny", "required": "Wymagane", "save": "Zapisz kontakt", @@ -569,7 +827,9 @@ "systemInformation": "Informacje systemowe", "tabs": { "details": "Szczegóły", - "notes": "Notatki" + "files": "Pliki", + "notes": "Notatki", + "preplan": "Plan operacyjny" }, "threads": "Threads", "title": "Kontakty", @@ -578,7 +838,13 @@ "unknown_person": "Nieznana osoba", "visibility": "Widoczność", "website": "Strona internetowa", - "zip": "Kod pocztowy" + "zip": "Kod pocztowy", + "withheld": "Ukryto {{count}} chronionych danych.", + "categoryLabel": "Kategoria: {{name}}", + "physicalAddress": "Adres fizyczny", + "mailingAddress": "Adres korespondencyjny", + "hasGeofence": "Ta lokalizacja ma wyznaczoną na mapie strefę geofence.", + "customFields": "Pola niestandardowe" }, "data_protection": { "conceal": "Ukryj ponownie", @@ -813,11 +1079,193 @@ "select_all": "Zaznacz wszystko", "selected_count": "Zaznaczono: {{count}}", "title": "Powiadomienia", - "unable_to_load": "Nie można załadować powiadomień" + "unable_to_load": "Nie można załadować powiadomień", + "detail_title": "Powiadomienie", + "additional_info": "Dodatkowe informacje", + "view_call": "Wyświetl zgłoszenie", + "view_chat": "Zobacz czat", + "open_reference": "Otwórz" }, "onboarding": { "message": "Witamy w aplikacji Resgrid Unit" }, + "operations": { + "all": "Wszystkie", + "back": "Wstecz", + "disabled": "Rozmieszczenia nie są włączone dla tego wydziału.", + "entryType": { + "0": "Rozmieszczenie", + "1": "Gotowość", + "2": "Przejazd" + }, + "errors": { + "denied": "Nie masz dostępu do tego rozmieszczenia.", + "disabled": "Ta funkcja jest wyłączona dla Twojego wydziału.", + "locked": "Odblokuj dane chronione, aby kontynuować.", + "retry": "Brak połączenia z serwerem. Spróbuj ponownie.", + "validation": "Serwer odrzucił zmianę.", + "timereports_subject_covered": "Ta załoga lub osoba ma już czas w innym raporcie z tego dnia.", + "timereports_date_exists": "Raport dla tej załogi lub osoby już istnieje w tym dniu.", + "timereports_scope_invalid": "Ta załoga lub osoba nie jest aktywna w tym rozmieszczeniu.", + "timereports_locked": "Tego raportu nie można już zmienić.", + "timereports_time_invalid": "Wpisz godziny w formacie HH:mm.", + "deployments_closed": "To rozmieszczenie jest zamknięte.", + "expenses_receipt_invalid": "Nie udało się odczytać paragonu.", + "deployments_attachment_too_large": "Paragon jest za duży." + }, + "financeMode": { + "0": "Operacyjne", + "1": "Zwrot kosztów", + "2": "Rozliczane" + }, + "issues": { + "end_before_start": "Godzina zakończenia musi być późniejsza niż rozpoczęcia.", + "missing_subject": "Wybierz, kogo lub czego dotyczy ten wpis.", + "missing_time": "Podaj godzinę rozpoczęcia i zakończenia.", + "overlap": "Ten wpis nakłada się na inny dla tej samej osoby lub jednostki.", + "subject_outside_report_scope": "Ten wpis dotyczy kogoś spoza załogi lub osoby tego raportu.", + "subject_on_other_report": "Ta osoba lub jednostka ma już czas w innym raporcie z tego samego dnia.", + "subject_not_on_roster": "Ten wpis dotyczy kogoś, kogo nie ma w składzie.", + "entries_overlap": "Dwa wpisy tej samej osoby lub jednostki nakładają się.", + "no_entries": "Dodaj co najmniej jeden wpis.", + "break_rule": "Długa zmiana nie ma zapisanej przerwy.", + "long_travel": "Więcej podróży niż dozwolone w ciągu dnia; wymaga zatwierdzenia.", + "outside_report_date": "Ten wpis wykracza poza datę raportu." + }, + "mars": { + "counts": "{{errors}} błędów · {{warnings}} ostrzeżeń", + "draft": "Przygotuj F-42 z raportów czasu", + "f42": "F-42", + "help": "F-42 jest przygotowywany z przesłanych raportów czasu i sprawdzany według zasad portalu CAL OES. Złożenie pozostaje ręcznym, audytowanym krokiem w wersji web.", + "none": "Brak elementów MARS dla tego rozmieszczenia.", + "notReady": "Niegotowe do portalu", + "ready": "Gotowe do portalu", + "record": "MARS {{id}} · {{status}}", + "title": "F-42", + "validate": "Sprawdź" + }, + "noNumbers": "Brak numerów zdarzenia lub zgłoszenia", + "none": "Brak rozmieszczeń.", + "open": "Otwarte", + "phase": { + "0": "Mobilizacja", + "1": "Gotowość", + "2": "Zdarzenie", + "3": "Powrót" + }, + "reportStatus": { + "0": "Szkic", + "1": "Przesłany", + "2": "Zatwierdzony", + "3": "Rozliczony", + "4": "Anulowany" + }, + "roster": "{{units}} jednostek · {{personnel}} osób", + "status": { + "0": "Zaplanowane", + "1": "Gotowość", + "2": "Aktywne", + "3": "Demobilizacja", + "4": "Zakończone", + "5": "Anulowane" + }, + "time": { + "accommodation": "Zakwaterowanie zapewnione przez agencję", + "addEntry": "Dodaj wpis", + "from": "Od", + "hours": "{{hours}} h", + "meals": "Posiłki zapewnione przez agencję", + "noEntries": "Brak wpisów.", + "noReport": "Brak raportu czasu na ten dzień.", + "notes": "Notatki", + "remove": "Usuń", + "report": "Raport #{{number}}", + "save": "Zapisz wpisy", + "start": "Rozpocznij raport czasu", + "subject": "Kto lub co", + "submit": "Prześlij raport", + "title": "Czas", + "to": "Do", + "total": "Razem {{hours}} h", + "type": "Rodzaj wpisu", + "unpaidBreak": "Przerwa bezpłatna (min)", + "crewReport": "Raport czasu załogi #{{number}} · {{name}}", + "individualReport": "Raport czasu #{{number}} · {{name}}", + "noCrewReport": "Brak raportu czasu załogi {{name}} w tym dniu.", + "coveredBy": "Ten czas jest już w raporcie #{{number}} z tego dnia.", + "startCrew": "Rozpocznij raport czasu załogi", + "readOnly": "Możesz przeglądać ten raport, ale nie możesz go zmienić.", + "nextDay": "Kończy się następnego dnia", + "applyToCrew": "Zastosuj do załogi", + "approve": "Zatwierdź raport", + "dayReports": "Raporty z tego dnia", + "awaitingApproval": "Oczekuje na zatwierdzenie ({{count}})", + "notOnRoster": "Nie jesteś w składzie ani w załodze tego rozmieszczenia." + }, + "title": "Rozmieszczenia", + "usage": { + "add": "Dodaj odczyt", + "endOdometer": "Licznik końcowy", + "engineHours": "Motogodziny", + "fuel": "Paliwo", + "needsReview": "Wymaga sprawdzenia", + "phase": "Faza", + "saved": "Odczyt zapisany.", + "startOdometer": "Licznik początkowy", + "title": "Użycie", + "unit": "Jednostka" + }, + "crewOf": "Załoga {{names}}", + "previousDay": "Poprzedni dzień", + "nextDay": "Następny dzień", + "scope": { + "deployment": "Całe rozmieszczenie", + "mine": "Mój czas", + "crew": "Załoga: {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "Podpisy", + "crewBossSigned": "Podpisane przez dowódcę załogi {{when}}", + "crewBossPending": "Jeszcze nie podpisane przez dowódcę załogi.", + "customerSigned": "Podpisujący z ramienia instytucji: {{name}}", + "customerPending": "Brak podpisu instytucji.", + "asCrewBoss": "Podpisz jako dowódca załogi", + "customerName": "Imię i nazwisko podpisującego z instytucji", + "recordCustomer": "Zapisz podpis instytucji" + }, + "expenses": { + "title": "Wydatki", + "type": { + "0": "Dieta posiłkowa", + "1": "Zakwaterowanie", + "2": "Zakwaterowanie prywatne", + "3": "Prom", + "4": "Paliwo", + "5": "Uzupełnienie zapasów", + "6": "Inne" + }, + "typeLabel": "Rodzaj wydatku", + "amount": "Kwota ({{currency}})", + "description": "Opis", + "city": "Miasto", + "takePhoto": "Zrób zdjęcie", + "choosePhoto": "Wybierz zdjęcie", + "receiptAttached": "Dołączono zdjęcie paragonu.", + "save": "Dodaj wydatek", + "add": "Nowy wydatek", + "noneToday": "Brak wydatków w tym dniu.", + "otherDays": "Inne dni", + "hasReceipt": "paragon", + "remove": "Usuń", + "removeTitle": "Usunąć wydatek?", + "removeBody": "Spowoduje to usunięcie wydatku i jego paragonu z rozmieszczenia.", + "cancel": "Anuluj", + "saved": "Wydatek zapisany.", + "amountInvalid": "Wpisz kwotę, np. 42.50.", + "photoDenied": "Zezwól na dostęp do aparatu lub zdjęć, aby dołączyć paragon." + } + }, "protocols": { "details": { "close": "Zamknij", @@ -846,6 +1294,172 @@ "unknown_type_warning": "Ten typ powiadomienia nie jest rozpoznawany. Skontaktuj się z pomocą techniczną, jeśli ten komunikat będzie się powtarzał.", "view_call": "Wyświetl zgłoszenie" }, + "records": { + "add_row": "Dodaj wiersz", + "agencies": "Agencje", + "agreement": "Umowa", + "assigned": "Przypisane do Ciebie", + "attachment_failed": "Nie udało się wysłać załącznika.", + "attachment_file_missing": "Pliku już nie ma na tym urządzeniu.", + "attachment_unreadable": "Nie udało się odczytać pliku.", + "attachment_will_resume": "Wstrzymano. Wznowi się w miejscu przerwania.", + "attachments": "Załączniki", + "attachments_hint": "Załączniki dodaje się po zapisaniu rekordu.", + "attestation": "Poświadczam, że ten rekord jest kompletny i poprawny", + "back": "Wstecz", + "busy": "Odczyt już trwa", + "camera_denied": "Aparat nie jest dostępny bez uprawnienia.", + "cancel": "Anuluj", + "captured_on": "Zarejestrowano", + "choose": "Wybierz…", + "choose_file": "Wybierz plik", + "choose_photo": "Wybierz zdjęcie", + "closed_out_on": "Zamknięto", + "closeout_notes": "Uwagi zamknięcia", + "conflict_app_version": "Rekordy nie są włączone dla tej wersji aplikacji.", + "conflict_definition_retired": "Ten formularz wycofano. Rozpocznij ponownie z aktualnej listy.", + "conflict_etag": "Ktoś inny zmienił ten rekord. Wczytaj go ponownie i nanieś zmiany.", + "conflict_permission": "Nie masz już dostępu do tego rekordu.", + "conflict_protected_data": "Ten formularz wymaga aktywnego połączenia i dostępu do danych chronionych.", + "conflict_scope": "Twój dostęp się zmienił, więc rekord wczytano ponownie.", + "connector_disabled": "Wyłączony", + "connector_maintained": "Utrzymywane przez łącznik", + "connector_not_ready": "Niegotowy", + "connector_ready": "Gotowy", + "connectors": "Zewnętrzne łączniki", + "connectors_hint": "Każdy łącznik odczytuje jeden system zamówień na jego warunkach, z zaszyfrowanym poświadczeniem i limitem żądań. Uprawnienie do zapisu jest odrzucane.", + "connectors_home_hint": "Kanały systemów zamówień i to, co wymaga uzgodnienia", + "cost_code": "Kod kosztów", + "deployment_status_closed_out": "Zamknięte", + "deployment_status_mobilized": "Zmobilizowane", + "deployment_status_open": "Otwarte", + "deployment_status_released": "Zwolnione", + "deployments": "Rozmieszczenia", + "deployments_hint": "System zamówień pozostaje wiążący. Obsady zmienia koordynator w przeglądarce; tutaj nic się nie zmienia.", + "deployments_home_hint": "Zewnętrzne zamówienia zasobów obsadzane przez Twój wydział", + "drafts": "Wersje robocze", + "field_required": "To pole jest wymagane.", + "fill_status_accepted": "Przyjęte", + "fill_status_assigned": "Przydzielone", + "fill_status_checked_in": "Zameldowane", + "fill_status_declined": "Odrzucone", + "fill_status_demobilized": "Zdemobilizowane", + "fill_status_mobilized": "Zmobilizowane", + "fill_status_released": "Zwolnione", + "fill_status_requested": "Zamówione", + "fill_status_returned": "Powrócono", + "fills": "Żądania i obsady", + "finalize": "Sfinalizuj", + "finalized": "Sfinalizowano.", + "fix_fields": "Niektóre pola wymagają jeszcze uwagi.", + "forbidden": "Tylko administratorzy wydziału", + "granted": "Przyznane", + "hide_closed": "Ukryj zamknięte rozmieszczenia", + "include_closed": "Pokaż zamknięte rozmieszczenia", + "last_error": "Ostatni błąd", + "last_read": "Ostatni odczyt", + "last_success": "Ostatnie powodzenie", + "load_failed": "Nie udało się wczytać rekordu.", + "loading": "Ładowanie rekordów…", + "location_kept": "Zachowano lokalizację", + "location_removed": "Usunięto lokalizację", + "manage_on_web": "Łączniki tworzy się, zmienia i wyposaża w poświadczenia w przeglądarce.", + "minutes": "{{count}} min", + "needed_on": "Potrzebne", + "needs_attention": "Wymaga Ciebie", + "never_read": "Nigdy nie odczytano", + "new_record": "Nowy rekord", + "no_assignments": "Nic nie jest Ci przypisane.", + "no_attachments": "Brak załączników.", + "no_connectors": "Brak łączników", + "no_deployments": "Brak rozmieszczeń", + "no_drafts": "Nie masz wersji roboczych.", + "no_fills": "Brak żądań w tym zamówieniu", + "no_recent": "Brak ostatnich rekordów.", + "no_reconciliation": "Brak rozbieżności między źródłem a Twoimi rekordami", + "no_returned": "Nic Ci nie zwrócono.", + "no_rows": "Brak wierszy.", + "no_runs": "Brak uruchomień", + "not_found": "Ten rekord jest niedostępny.", + "not_granted": "Nieprzyznane", + "not_sent_yet": "Oczekuje na wysłanie", + "nothing_to_create": "Obecnie nie można stąd nic rozpocząć.", + "offices": "Biura", + "offline_capable": "Działa offline", + "online_only": "Ten formularz wypełnia się tylko online.", + "online_only_badge": "Tylko online", + "open_artifact": "Otwórz artefakt źródłowy", + "open_record": "Otwórz rekord", + "pending": "Niewysłane", + "poll_interval": "Interwał odpytywania", + "preview": "Podgląd", + "purpose_acknowledge": "Potwierdź ten rekord", + "purpose_attach": "Dodaj załącznik", + "purpose_complete": "Uzupełnij ten rekord", + "purpose_correct": "Popraw ten rekord", + "purpose_review": "Zweryfikuj ten rekord", + "read_authority": "Uprawnienie do odczytu", + "read_feed_now": "Odczytaj kanał teraz", + "reading_feed": "Odczyt…", + "reason_app_disabled": "Rekordy nie są włączone dla tej aplikacji w Twojej jednostce.", + "reason_app_version_too_old": "Zaktualizuj aplikację, aby korzystać z rekordów.", + "reason_module_disabled": "Rekordy nie są dostępne dla Twojej jednostki.", + "reason_not_member": "Nie jesteś aktywnym członkiem tej jednostki.", + "reason_records_not_usable": "Twoja jednostka nie aktywowała jeszcze rekordów.", + "recent": "Ostatnie", + "reconciliation": "Uzgodnienie", + "reconciliation_count": "{{count}} do uzgodnienia", + "reconciliation_hint": "Gdzie źródło i Twój rekord się różnią. Nic nie jest stosowane automatycznie; koordynator decyduje w przeglądarce.", + "reconciliation_local_fill_missing_in_source": "Obsada istnieje lokalnie, ale nie w źródle", + "reconciliation_source_closed_local_open": "Źródło zamknęło zamówienie, ale obsady są nadal otwarte", + "reconciliation_source_released_local_not_returned": "Źródło zwolniło zamówienie, ale obsada jeszcze nie wróciła", + "reconciliation_source_request_missing_locally": "Żądanie istnieje w źródle, ale nie lokalnie", + "reconciliation_source_status_ahead": "Status źródła wyprzedza lokalną obsadę", + "reconciliation_source_status_behind": "Status źródła jest za lokalną obsadą", + "refresh_catalog": "Odśwież", + "requests_this_hour": "Żądania w tej godzinie", + "restricted": "Zastrzeżone", + "returned": "Zwrócone do Ciebie", + "run_counts": "Widziane {{seen}} · utworzone {{created}} · migawki {{snapshots}} · żądania {{requests}} · bez zmian {{unchanged}} · odrzucone {{rejected}} · do uzgodnienia {{conflicts}}", + "run_failed": "Nie udało się odczytać kanału", + "run_finished": "Kanał odczytany {{outcome}}: {{created}} utworzono, {{snapshots}} migawek, {{requests}} żądań, {{conflicts}} do uzgodnienia", + "run_log": "Dziennik uruchomień", + "run_not_ok": "Odczyt kanału zakończył się: {{outcome}}", + "run_outcome_disabled": "Wyłączony", + "run_outcome_failed": "Niepowodzenie", + "run_outcome_ok": "OK", + "run_outcome_rate_limited": "Limit żądań", + "run_outcome_rejected": "Odrzucone", + "run_trigger_inbound": "Przesłane przez źródło", + "run_trigger_manual": "Ręczne", + "run_trigger_poll": "Zaplanowane", + "save": "Zapisz", + "save_and_send": "Zapisz i wyślij", + "save_failed": "Nie udało się zapisać.", + "save_locally": "Zachowaj na tym urządzeniu", + "saved": "Zapisano.", + "saved_locally": "Zachowano na tym urządzeniu. Wyśle się po powrocie online.", + "sign_statement": "Podpisuję ten rekord jako autor", + "source": "Źródło", + "source_says": "Źródło:", + "submit_for_review": "Wyślij do weryfikacji", + "submitted": "Wysłano do weryfikacji.", + "take_photo": "Zrób zdjęcie", + "terms": "Warunki źródła", + "terms_acknowledged": "Potwierdzone", + "terms_not_acknowledged": "Jeszcze niepotwierdzone", + "title": "Rekordy", + "trainee": "Stażysta", + "unavailable": "Rekordy nie są teraz dostępne w tej aplikacji.", + "unit_code": "Jednostka", + "unsupported_definition": "Ten formularz używa pola, którego ta wersja aplikacji nie wypełni. Zaktualizuj aplikację lub użyj aplikacji internetowej.", + "unsupported_field": "Ta aplikacja nie może jeszcze pokazać tego pola. Wypełnij je w aplikacji internetowej.", + "untitled": "Bez tytułu", + "update_required": "Wymagana aktualizacja aplikacji", + "write_authority": "Uprawnienie do zapisu", + "write_refused": "Odrzucone – nic nie jest zapisywane z powrotem", + "your_record_says": "Twój rekord:" + }, "roles": { "assignedElsewhere": "W innej funkcji", "clearAssignment": "Wyczyść bieżący przydział", @@ -1117,6 +1731,17 @@ "version": "Wersja", "website": "Strona internetowa" }, + "site_info": { + "additional_contact": "Kontakt dodatkowy", + "alert_notes": "Notatki alarmowe", + "empty": "Brak informacji o obiekcie", + "empty_description": "Żaden kontakt powiązany z tym zgłoszeniem nie ma planu operacyjnego, zagrożeń, notatek alarmowych ani plików.", + "entrance": "Wejście", + "files": "Pliki obiektu", + "load_failed": "Nie udało się załadować informacji o obiekcie.", + "loading": "Ładowanie informacji o obiekcie...", + "primary_contact": "Kontakt główny" + }, "status": { "add_note": "Dodaj notatkę", "both_destinations_enabled": "Może reagować na zgłoszenia lub stacje", @@ -1157,6 +1782,7 @@ "map": "Mapa", "notes": "Notatki", "protocols": "Protokoły", + "records": "Rekordy", "routes": "Trasy", "settings": "Ustawienia", "weather_alerts": "Pogoda" @@ -1277,5 +1903,80 @@ "unknown": "Nieznany" } }, - "welcome": "Witamy w aplikacji Resgrid Unit" + "welcome": "Witamy w aplikacji Resgrid Unit", + "inventory": { + "title": "Wyposażenie", + "titleFor": "Wyposażenie · {{name}}", + "selectUnit": "Najpierw wybierz swój pojazd.", + "unnamedLocation": "Skrytka", + "unnamedItem": "Pozycja", + "countName": "Kontrola {{name}} {{date}}", + "notMigrated": "Wyposażenie jest konfigurowane dla Twojej organizacji.", + "disabled": "Wyposażenie jest wyłączone w Twojej organizacji.", + "noLocations": "Do tego pojazdu nie przypisano wyposażenia.", + "startCount": "Policz wyposażenie", + "wholeUnit": "Cały pojazd: {{name}}", + "pickLocation": "Gdzie liczyć", + "startHelp": "Policz to, co faktycznie jest w pojeździe. Różnice zostaną zaksięgowane po zakończeniu.", + "start": "Rozpocznij liczenie", + "cannotCount": "Liczenie wymaga uprawnienia do korekty wyposażenia w tym pojeździe.", + "counts": "Ostatnie liczenia", + "count": "Liczenie wyposażenia", + "empty": "Nic tu nie ma.", + "serial": "Nr seryjny {{serial}}", + "quantity": "× {{quantity}}", + "back": "Wstecz", + "completeTitle": "Zakończyć liczenie?", + "completeBody": "{{count}} pozycji różni się od oczekiwanych. Zakończenie zaksięguje różnice.", + "completeNoChanges": "Wszystko zgadza się z oczekiwanym.", + "cancel": "Anuluj", + "complete": "Zakończ", + "cancelTitle": "Anulować to liczenie?", + "cancelBody": "Nic z policzonego nie zostanie zaksięgowane.", + "keep": "Liczę dalej", + "cancelCount": "Anuluj liczenie", + "progress": "Policzono {{counted}} z {{total}}", + "differences": "{{count}} różnic", + "awaitingWitness": "Zmieniła się pozycja kontrolowana; druga osoba musi poświadczyć to liczenie w przeglądarce.", + "completed": "Liczenie zakończone.", + "expected": "Oczekiwano {{quantity}} {{unit}}", + "present": "Jest", + "missing": "Brak", + "less": "O jeden mniej", + "more": "O jeden więcej", + "matches": "Zgadza się", + "save": "Zapisz postęp", + "countStatus": { + "0": "W toku", + "1": "Oczekuje na świadka", + "2": "Zakończone", + "3": "Anulowane" + }, + "assetStatus": { + "0": "W użyciu", + "1": "Wydane", + "2": "W naprawie", + "3": "Uszkodzone", + "4": "Zgubione", + "5": "Zużyte", + "6": "Wycofane" + }, + "errors": { + "retry": "Nie udało się połączyć z serwerem. Spróbuj ponownie.", + "locked": "Odblokuj chronione dane, aby kontynuować.", + "PermissionRequired": "Nie masz uprawnień do tej czynności.", + "MembershipRequired": "Nie jesteś aktywnym członkiem tej organizacji.", + "InventoryDisabled": "Wyposażenie jest wyłączone w Twojej organizacji.", + "MigrationRequired": "Wyposażenie jest konfigurowane dla Twojej organizacji.", + "CountScopeTooLarge": "Zbyt wiele pozycji na jedno liczenie. Liczy po jednej skrytce.", + "CountEmpty": "Nie ma tu nic do policzenia.", + "CountSnapshotChanged": "Wyposażenie pojazdu zmieniło się podczas liczenia. Anuluj i zacznij od nowa.", + "CountStateConflict": "To liczenie zmieniło się na innym urządzeniu. Otwórz je ponownie.", + "ReturnAssetFirst": "Pozycja oznaczona jako brakująca jest nadal komuś wydana. Najpierw przyjmij zwrot w przeglądarce.", + "ProtectedDataRequired": "Odblokuj chronione dane, aby kontynuować.", + "CountAlreadyExists": "To liczenie już istnieje.", + "LocationUnavailable": "To miejsce jest niedostępne.", + "InvalidCount": "Sprawdź policzone wartości i spróbuj ponownie." + } + } } diff --git a/src/translations/sv.json b/src/translations/sv.json index f93133f4..4c6c60a1 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -126,6 +126,12 @@ "title": "Samtalsanteckningar" }, "call_detail": { + "activity_link": { + "auto": "Automatiskt länkad", + "auto_hint": "Skickad utan samtal; Resgrid länkade den från föregående status eller utskicket.", + "inferred": "Härledd", + "inferred_hint": "Satt utan destination av en enhet eller person utskickad till detta samtal medan den arbetade med det." + }, "address": "Adress", "call_location": "Samtalets plats", "close_call": "Stäng samtal", @@ -201,6 +207,7 @@ "dispatched": "Utskickad", "info": "Info", "protocols": "Protokoll", + "site": "Objektinfo", "timeline": "Aktivitet" }, "timestamp": "Tidsstämpel", @@ -228,6 +235,9 @@ "contact_name_placeholder": "Ange kontaktens namn", "contact_phone": "Kontakttelefon", "contact_phone_placeholder": "Ange kontaktens telefon", + "contact_picker": { + "linked": "Kopplad kontakt: {{name}}" + }, "coordinates": "GPS-koordinater", "coordinates_found": "Koordinater hittades och adressen uppdaterades", "coordinates_geocoding_error": "Det gick inte att hämta adress för koordinater, men platsen är satt på kartan", @@ -445,6 +455,206 @@ "type_sector_rotation": "Sektorsrotation", "type_unit": "Enhet" }, + "checklists": { + "errors": { + "conflict": "Kontrollen har ändrats på servern. Ditt lokala arbete har sparats; granska det innan du försöker igen.", + "denied": "Åtkomst saknas. Ditt krypterade lokala arbete har sparats.", + "locked": "Lås upp skyddade uppgifter för att fortsätta.", + "offline": "Utan anslutning: säkert sparade kontroller visas.", + "retry": "Synkroniseringen kunde inte slutföras. Ditt lokala arbete har sparats.", + "storage_full": "Den lokala gränsen för utkast eller underlag har nåtts.", + "validation": "Kontrollera obligatoriska svar, datum, anteckningar och underlag och försök igen." + }, + "labels": { + "Actions": "Åtgärder", + "Active": "Aktiv", + "AnchorMonth": "Referensmånad", + "Annual": "Årligen", + "Answer": "Svar", + "Answered": "Besvarad", + "AssetUnavailable": "Utrustning med serienummer är inte tillgänglig.", + "Assignment": "Tilldelad till", + "AssignmentAutomatic": "Automatisk tilldelning", + "AssignmentPermission": "Du har inte tilldelats att utföra den här kontrollen.", + "AssignmentType1": "Enskild person", + "AssignmentType2": "Personalroll", + "AssignmentType3": "Grupp eller station", + "AssignmentType4": "Enhetens besättning", + "AssignmentUnavailable": "Välj en tillgänglig tilldelning i den här organisationen.", + "Attest": "Intyga och slutför", + "Attestation": "Intyg om oberoende kontroll", + "Author": "Utförare", + "AwaitingWitness": "Inväntar vittne", + "Back": "Tillbaka", + "CalendarInclude": "Beredskapskontroller", + "CalendarRange": "Välj ett kalenderintervall på högst 93 dagar.", + "Cancelled": "Avbruten", + "Category": "Kategori", + "Checkbox": "Kryssruta", + "Checked": "Markerad", + "ChecklistCompleted": "Checklista slutförd", + "ChecklistFailed": "Checklista underkänd", + "ChecklistMissed": "Checklista missad", + "ChecklistOccurrenceSkipped": "Undantag från planerad kontroll godkänt", + "ChecklistScheduleChanged": "Kontrollschema ändrat", + "Checklists": "Checklistor", + "Checks": "Kontroller", + "Choose": "Välj", + "Critical": "Kritisk", + "Daily": "Dagligen", + "Date": "Datum", + "DayOfMonth": "Dag i månaden", + "DeleteDraft": "Ta bort utkast", + "Department": "Avdelning", + "Draft": "Utkast", + "DraftGuidance": "Spara ett utkast och publicera det. Befintliga körningar behåller sin ursprungliga version. Ange inga patientuppgifter.", + "DueChecks": "Kontroller att utföra", + "DueGuidance": "Kommande kontroller för sju dagar och oavslutade kontroller. Missade kontroller kan fortfarande slutföras.", + "EditChecklist": "Redigera checklista", + "EditDraft": "Redigera utkast", + "EditSchedule": "Redigera schema", + "Evidence": "Underlag", + "ExcuseCheck": "Godkänn undantag", + "Export": "Exportera JSON", + "Facility": "Anläggning", + "Fail": "Underkänd", + "FailNote": "En underkänd kontroll kräver en anteckning.", + "Failed": "Underkänd", + "Free": "Checklistor är kostnadsfria för alla avdelningar.", + "Frequency": "Frekvens", + "Guidance": "Anpassa mallarna till utrustningen, tillverkarens rutiner och lokala krav före användning.", + "History": "Historik", + "InProgress": "Pågår", + "Incomplete": "Ofullständig", + "InventoryAsset": "Utrustning med serienummer", + "Item": "Punkt", + "Maintenance": "Underhåll och arbetsorder (Readiness Pro)", + "Missed": "Missad", + "MonthGuidance": "För månatliga eller mindre frekventa kontroller. Korta månader använder sin sista dag. Referensmånaden styr kvartalsvisa, halvårsvisa och årliga kontroller.", + "Monthly": "Varje månad", + "Name": "Namn", + "NewChecklist": "Ny checklista", + "NewSchedule": "Nytt schema", + "Next": "Nästa", + "No": "Nej", + "NoDefinitions": "Inga checklistor på den här sidan.", + "NoDueChecks": "Inga kontroller i den här listan.", + "NoResults": "Inga matchande mallar.", + "NoSchedules": "Det finns inga scheman ännu.", + "NoScore": "Ingen tillämplig poäng", + "Note": "Anteckning", + "OccurrenceUnavailable": "Den här kontrollen är inte tillgänglig eller har redan ändrats.", + "OpenCheck": "Öppna kontroll", + "Optional": "Valfri", + "Other": "Övrigt", + "Pass": "Godkänd", + "Passed": "Godkänd", + "PerShift": "Varje arbetspass", + "Personnel": "Personal", + "Photo": "Foto", + "Preview": "Förhandsgranska mall", + "Previous": "Föregående", + "Print": "Skriv ut", + "PublishDraft": "Publicera utkast", + "PublishGuidance": "Publicering skapar en oföränderlig version för nya körningar.", + "Published": "Publicerad", + "PublishedVersion": "Publicerad version", + "Quantity": "Antal", + "Quarterly": "Varje kvartal", + "ReminderBefore": "Minuter före förfallotiden (0 inaktiverar)", + "ReminderDigest": "Samla väntande påminnelser i sammanställningar", + "ReminderEnabled": "Aktivera påminnelser om checklistor", + "ReminderEscalate": "Meddela avdelningsadministratörer efter så här många minuters försening (tomt inaktiverar)", + "ReminderFixedTime": "Tid för daglig sammanställning (organisationens tidszon; ersätter skiftstart)", + "ReminderHelp": "Påminnelser gäller kontroller som börjar efter att påminnelserna aktiverats. Meddelanden innehåller en inloggningslänk och följer varje persons aviseringsinställningar. Sammanställningar samlar väntande påminnelser per mottagare.", + "ReminderMessage": "Det finns uppdateringar av checklistor. Logga in för att granska dina kontroller.", + "ReminderMissed": "Meddela ansvarig personal om missade kontroller", + "ReminderSettings": "Påminnelser om checklistor", + "ReminderShiftStart": "Skicka en sammanställning vid skiftstart", + "ReminderTimeValidation": "Ange en enda tid i formatet HH:mm.", + "ReminderTitle": "Påminnelser om checklistor", + "ReminderValidation": "Ange ett påminnelseintervall på 0–10080 minuter och ett intervall för avisering till administratörer på 1–43200 minuter, eller lämna det senare tomt.", + "Remove": "Ta bort", + "ReportedLocation": "Rapporterad plats", + "Required": "Obligatorisk", + "Result": "Resultat", + "Retire": "Avveckla", + "Retired": "Avvecklad", + "RunGuidance": "Besvara varje tillämplig punkt. Ej tillämplig kräver en motivering. Spara förloppet för att fortsätta senare. Inskickade svar och underlag kan inte ändras.", + "SaveDraft": "Spara utkast", + "SaveProgress": "Spara förlopp", + "SaveReminders": "Spara påminnelseinställningar", + "SaveSchedule": "Spara schema", + "ScheduleEnabled": "Schema aktiverat", + "ScheduleEndDate": "Slutdatum (valfritt)", + "ScheduleNotes": "Schemaanteckningar", + "SchedulePaused": "Pausat", + "SchedulePermission": "Du får inte hantera kontrollscheman.", + "ScheduleRequiresPublished": "Publicera checklistan innan du schemalägger den.", + "ScheduleStartDate": "Startdatum", + "ScheduleTimeGuidance": "Tiderna följer den här tidszonen. En överhoppad klocktid flyttas framåt; en upprepad tid inträffar bara en gång.", + "ScheduleUnavailable": "Det här schemat är inte tillgängligt.", + "ScheduleValidation": "Kontrollera schemats datum, tider, mål och upprepning.", + "ScheduleVersionGuidance": "När du sparar låses den aktuella publicerade versionen. Ändringar ersätter framtida ej påbörjade kontroller; paus avbryter ej påbörjade kontroller.", + "ScheduleWorkshift": "Arbetspass för kontroller vid varje pass", + "Scheduled": "Planerad", + "ScheduledFor": "Planerad till", + "Schedules": "Scheman", + "Score": "Poäng", + "Search": "Sök", + "SearchLabel": "Hitta en mall", + "SelectTarget": "Välj ett mål", + "SemiAnnual": "Var sjätte månad", + "Signature": "Underskrift", + "SkipReason": "Orsak till undantaget", + "SkipReasonRequired": "Ange en orsak med högst 4 000 tecken.", + "Skipped": "Undantag godkänt", + "StartRun": "Starta checklista", + "Started": "Startad", + "State": "Status", + "SubmitRun": "Skicka in checklista", + "Submitted": "Inskickad", + "Target": "Mål", + "Templates": "Checklistemallar", + "TimeZone": "Tidszon", + "TimesOfDay": "Lokala tider", + "TimesOfDayGuidance": "Ange upp till åtta tider i 24-timmarsformat, åtskilda med kommatecken.", + "Unanswered": "Obesvarad", + "Unchecked": "Avmarkerad", + "Unit": "Enhet", + "Units": "Enheter", + "Version": "Version", + "Weekly": "Varje vecka", + "WeeklyDays": "Dagar för veckokontroller", + "WindowEnds": "Tidsfristen löper ut", + "WindowMinutes": "Tidsfrist för genomförande (minuter)", + "Witness": "Den inloggade utföraren och en annan inloggad medlem måste självständigt verifiera resultatet.", + "WitnessInstructions": "En annan inloggad medlem måste självständigt kontrollera inskickade svar och underlag. Dela länken till den här sidan med medlemmen.", + "WitnessLabel": "Vittne", + "WorkshiftFallback": "Använd lokala tider varje dag", + "WorkshiftGuidance": "Endast för kontroller vid varje arbetspass. Ett valt arbetspass använder sina registrerade startdatum; annars gäller lokala tider varje dag.", + "Yes": "Ja" + }, + "ui": { + "calendar": "Kalender", + "clearSignature": "Rensa underskrift", + "close": "Stäng", + "confirm": "Ersätt", + "editLocal": "Redigera lokalt utkast", + "localDrafts": "Lokala utkast", + "notApplicable": "Ej tillämpligt", + "prepareOffline": "Förbered för användning utan anslutning", + "queued": "Säkert sparat på enheten; väntar på synkronisering.", + "reason": "Orsak", + "refresh": "Uppdatera", + "removeImage": "Ta bort bild", + "replaceLocal": "Ersätta lokala svar och väntande bilder med serverversionen?", + "retry": "Försök synkronisera igen", + "takePhoto": "Ta foto", + "unlock": "Lås upp skyddade uppgifter", + "useServer": "Använd serverversion" + } + }, "common": { "add": "Lägg till", "back": "Tillbaka", @@ -536,6 +746,12 @@ "expires": "Upphör", "facebook": "Facebook", "faxPhone": "Fax", + "files": { + "download_failed": "Filen kunde inte laddas ner.", + "empty": "Inga filer har bifogats denna kontakt.", + "loading": "Laddar filer...", + "share_unavailable": "Delning är inte tillgänglig på den här enheten." + }, "formError": "Åtgärda felen i formuläret", "homePhone": "Hemtelefon", "identification": "Identifiering", @@ -557,6 +773,48 @@ "otherInfo": "Övrig information", "person": "Person", "phone": "Telefon", + "preplan": { + "access": "Tillträde", + "access_notes": "Anteckningar om tillträde", + "alarm_company": "Larmbolag", + "alarm_company_phone": "Telefon larmbolag", + "alarm_panel": "Larmcentral", + "alert_flag": "Varning", + "construction": "Konstruktion · tak · verksamhet", + "electric_shutoff": "Elavstängning", + "emergency_contact": "Nödkontakt", + "emergency_contact_phone": "Telefon nödkontakt", + "gas_shutoff": "Gasavstängning", + "gate_code": "Grindkod", + "general_hazard_notes": "Allmänna riskanteckningar", + "general_hazards": "Risker & taktik", + "hazards": "Risker på objektet", + "hazmat_on_site": "Farliga ämnen på platsen", + "knox_box": "Nyckelskåp", + "last_reviewed": "Senast granskad", + "loading": "Laddar insatsplan...", + "location": "Plats", + "nearest_hydrant": "Närmaste brandpost", + "never_reviewed": "Aldrig granskad", + "next_review": "Nästa granskning", + "none": "Ingen insatsplan har registrerats för denna kontakt.", + "occupancy": "Konstruktion & verksamhet", + "occupancy_hours": "Verksamhetstider", + "occupancy_notes": "Anteckningar om verksamheten", + "occupant_load": "Antal personer", + "occupants_needing_assistance": "Personer kan behöva hjälp att utrymma", + "on_site_contacts": "Kontakter på platsen", + "required_fire_flow": "Erforderligt flöde", + "review_overdue": "Insatsplanens granskningsdatum har passerat.", + "secondary_contact": "Sekundär kontakt", + "secondary_contact_phone": "Telefon sekundär kontakt", + "tactical_summary": "Taktisk sammanfattning", + "utilities": "Avstängningar", + "utility_notes": "Anteckningar om avstängningar", + "water_shutoff": "Vattenavstängning", + "water_supply": "Vattenförsörjning", + "water_supply_notes": "Anteckningar om vattenförsörjning" + }, "public": "Offentlig", "required": "Obligatorisk", "save": "Spara kontakt", @@ -569,7 +827,9 @@ "systemInformation": "Systeminformation", "tabs": { "details": "Detaljer", - "notes": "Anteckningar" + "files": "Filer", + "notes": "Anteckningar", + "preplan": "Insatsplan" }, "threads": "Threads", "title": "Kontakter", @@ -578,7 +838,13 @@ "unknown_person": "Okänd person", "visibility": "Synlighet", "website": "Webbplats", - "zip": "Postnummer" + "zip": "Postnummer", + "withheld": "{{count}} skyddade uppgifter är dolda.", + "categoryLabel": "Kategori: {{name}}", + "physicalAddress": "Besöksadress", + "mailingAddress": "Postadress", + "hasGeofence": "Platsen har ett inritat geostaket.", + "customFields": "Anpassade fält" }, "data_protection": { "conceal": "Dölj igen", @@ -813,11 +1079,193 @@ "select_all": "Markera alla", "selected_count": "{{count}} markerade", "title": "Aviseringar", - "unable_to_load": "Det gick inte att läsa in aviseringarna" + "unable_to_load": "Det gick inte att läsa in aviseringarna", + "detail_title": "Avisering", + "additional_info": "Ytterligare information", + "view_call": "Visa samtal", + "view_chat": "Visa chatt", + "open_reference": "Öppna" }, "onboarding": { "message": "Välkommen till Resgrid Unit app site" }, + "operations": { + "all": "Alla", + "back": "Tillbaka", + "disabled": "Insatser är inte aktiverade för den här avdelningen.", + "entryType": { + "0": "Insats", + "1": "Beredskap", + "2": "Resa" + }, + "errors": { + "denied": "Du har inte åtkomst till den här insatsen.", + "disabled": "Funktionen är avstängd för din avdelning.", + "locked": "Lås upp skyddade data för att fortsätta.", + "retry": "Kunde inte nå servern. Försök igen.", + "validation": "Servern avvisade ändringen.", + "timereports_subject_covered": "Den besättningen eller personen har redan tid i en annan rapport denna dag.", + "timereports_date_exists": "Det finns redan en rapport för den besättningen eller personen den dagen.", + "timereports_scope_invalid": "Den besättningen eller personen är inte aktiv i denna insats.", + "timereports_locked": "Rapporten kan inte längre ändras.", + "timereports_time_invalid": "Ange tider som HH:mm.", + "deployments_closed": "Insatsen är avslutad.", + "expenses_receipt_invalid": "Kvittot kunde inte läsas.", + "deployments_attachment_too_large": "Kvittot är för stort." + }, + "financeMode": { + "0": "Operativ", + "1": "Kostnadsåtervinning", + "2": "Fakturerbar" + }, + "issues": { + "end_before_start": "Sluttiden måste vara efter starttiden.", + "missing_subject": "Välj vem eller vad posten gäller.", + "missing_time": "Ange start- och sluttid.", + "overlap": "Posten överlappar en annan för samma person eller enhet.", + "subject_outside_report_scope": "Posten gäller någon utanför rapportens besättning eller person.", + "subject_on_other_report": "Den här personen eller enheten har redan tid i en annan rapport samma dag.", + "subject_not_on_roster": "Posten gäller någon som inte finns i bemanningen.", + "entries_overlap": "Två poster för samma person eller enhet överlappar.", + "no_entries": "Lägg till minst en post.", + "break_rule": "Ett långt pass saknar registrerad rast.", + "long_travel": "Mer resa än tillåtet på en dag; kräver godkännande.", + "outside_report_date": "Posten ligger utanför rapportens datum." + }, + "mars": { + "counts": "{{errors}} fel · {{warnings}} varningar", + "draft": "Skapa F-42-utkast från tidrapporter", + "f42": "F-42", + "help": "F-42 skapas från inskickade tidrapporter och kontrolleras mot CAL OES-portalens regler. Inlämningen är fortfarande ett manuellt, loggat steg på webben.", + "none": "Inga MARS-ärenden för den här insatsen ännu.", + "notReady": "Inte redo för portalen", + "ready": "Redo för portalen", + "record": "MARS {{id}} · {{status}}", + "title": "F-42", + "validate": "Validera" + }, + "noNumbers": "Inga händelse- eller begärannummer", + "none": "Inga insatser.", + "open": "Öppna", + "phase": { + "0": "Mobilisering", + "1": "Beredskap", + "2": "Händelse", + "3": "Återresa" + }, + "reportStatus": { + "0": "Utkast", + "1": "Inskickad", + "2": "Godkänd", + "3": "Fakturerad", + "4": "Makulerad" + }, + "roster": "{{units}} enheter · {{personnel}} personer", + "status": { + "0": "Planerad", + "1": "Beredskap", + "2": "Aktiv", + "3": "Demobilisering", + "4": "Avslutad", + "5": "Avbruten" + }, + "time": { + "accommodation": "Boende tillhandahålls av myndigheten", + "addEntry": "Lägg till post", + "from": "Från", + "hours": "{{hours}} h", + "meals": "Måltider tillhandahålls av myndigheten", + "noEntries": "Inga poster ännu.", + "noReport": "Ingen tidrapport för den här dagen.", + "notes": "Anteckningar", + "remove": "Ta bort", + "report": "Rapport #{{number}}", + "save": "Spara poster", + "start": "Starta tidrapport", + "subject": "Vem eller vad", + "submit": "Skicka in rapport", + "title": "Tid", + "to": "Till", + "total": "Totalt {{hours}} h", + "type": "Posttyp", + "unpaidBreak": "Obetald rast (min)", + "crewReport": "Besättningens tidrapport #{{number}} · {{name}}", + "individualReport": "Tidrapport #{{number}} · {{name}}", + "noCrewReport": "Ingen tidrapport för besättningen {{name}} denna dag.", + "coveredBy": "Den här tiden finns redan i rapport #{{number}} för denna dag.", + "startCrew": "Starta besättningens tidrapport", + "readOnly": "Du kan se rapporten men inte ändra den.", + "nextDay": "Slutar nästa dag", + "applyToCrew": "Använd för besättningen", + "approve": "Godkänn rapport", + "dayReports": "Rapporter denna dag", + "awaitingApproval": "Väntar på godkännande ({{count}})", + "notOnRoster": "Du finns inte i bemanningen eller någon besättning i denna insats." + }, + "title": "Insatser", + "usage": { + "add": "Lägg till avläsning", + "endOdometer": "Mätarställning slut", + "engineHours": "Motortimmar", + "fuel": "Bränsle", + "needsReview": "Behöver granskas", + "phase": "Fas", + "saved": "Avläsning sparad.", + "startOdometer": "Mätarställning start", + "title": "Användning", + "unit": "Enhet" + }, + "crewOf": "Besättning på {{names}}", + "previousDay": "Föregående dag", + "nextDay": "Nästa dag", + "scope": { + "deployment": "Hela insatsen", + "mine": "Min tid", + "crew": "Besättning: {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "Underskrifter", + "crewBossSigned": "Signerad av besättningschefen {{when}}", + "crewBossPending": "Ännu inte signerad av besättningschefen.", + "customerSigned": "Myndighetens undertecknare: {{name}}", + "customerPending": "Ingen underskrift från myndigheten registrerad.", + "asCrewBoss": "Signera som besättningschef", + "customerName": "Myndighetens undertecknares namn", + "recordCustomer": "Registrera myndighetens underskrift" + }, + "expenses": { + "title": "Utlägg", + "type": { + "0": "Traktamente måltider", + "1": "Logi", + "2": "Privat logi", + "3": "Färja", + "4": "Bränsle", + "5": "Materialpåfyllning", + "6": "Övrigt" + }, + "typeLabel": "Typ av utlägg", + "amount": "Belopp ({{currency}})", + "description": "Beskrivning", + "city": "Ort", + "takePhoto": "Ta foto", + "choosePhoto": "Välj foto", + "receiptAttached": "Kvittofoto bifogat.", + "save": "Lägg till utlägg", + "add": "Nytt utlägg", + "noneToday": "Inga utlägg denna dag.", + "otherDays": "Andra dagar", + "hasReceipt": "kvitto", + "remove": "Ta bort", + "removeTitle": "Ta bort utlägget?", + "removeBody": "Utlägget och dess kvitto tas bort från insatsen.", + "cancel": "Avbryt", + "saved": "Utlägget sparades.", + "amountInvalid": "Ange ett belopp som 42.50.", + "photoDenied": "Tillåt åtkomst till kamera eller bilder för att bifoga ett kvitto." + } + }, "protocols": { "details": { "close": "Stäng", @@ -846,6 +1294,172 @@ "unknown_type_warning": "Denna aviseringstyp känns inte igen. Kontakta supporten om du fortsätter att se detta meddelande.", "view_call": "Visa samtal" }, + "records": { + "add_row": "Lägg till rad", + "agencies": "Myndigheter", + "agreement": "Avtal", + "assigned": "Tilldelade dig", + "attachment_failed": "Bilagan kunde inte skickas.", + "attachment_file_missing": "Filen finns inte längre på enheten.", + "attachment_unreadable": "Filen kunde inte läsas.", + "attachment_will_resume": "Pausad. Den fortsätter där den stannade.", + "attachments": "Bilagor", + "attachments_hint": "Bilagor läggs till efter att posten sparats.", + "attestation": "Jag intygar att denna post är fullständig och korrekt", + "back": "Tillbaka", + "busy": "En läsning pågår redan", + "camera_denied": "Kameran är inte tillgänglig utan behörighet.", + "cancel": "Avbryt", + "captured_on": "Fångad", + "choose": "Välj…", + "choose_file": "Välj en fil", + "choose_photo": "Välj ett foto", + "closed_out_on": "Avslutad", + "closeout_notes": "Avslutsanteckningar", + "conflict_app_version": "Poster är inte aktiverade för denna appversion.", + "conflict_definition_retired": "Detta formulär har avvecklats. Börja om från den aktuella listan.", + "conflict_etag": "Någon annan har ändrat posten. Ladda om den och gör om dina ändringar.", + "conflict_permission": "Du har inte längre åtkomst till posten.", + "conflict_protected_data": "Detta formulär kräver aktiv anslutning och din åtkomst till skyddade data.", + "conflict_scope": "Din åtkomst ändrades, så posten laddades om.", + "connector_disabled": "Inaktiverad", + "connector_maintained": "Underhålls av koppling", + "connector_not_ready": "Inte redo", + "connector_ready": "Redo", + "connectors": "Externa kopplingar", + "connectors_hint": "Varje koppling läser ett beställningssystem på dess villkor, med en krypterad behörighet och en begärandegräns. Skrivbehörighet nekas.", + "connectors_home_hint": "Beställningssystemens flöden och vad som behöver stämmas av", + "cost_code": "Kostnadskod", + "deployment_status_closed_out": "Avslutad", + "deployment_status_mobilized": "Mobiliserad", + "deployment_status_open": "Öppen", + "deployment_status_released": "Frigiven", + "deployments": "Insatser", + "deployments_hint": "Beställningssystemet förblir auktoritativt. Tillsättningar flyttas av en samordnare på webben; inget här ändrar någon.", + "deployments_home_hint": "Externa resursbeställningar som din avdelning tillsätter", + "drafts": "Utkast", + "field_required": "Detta fält är obligatoriskt.", + "fill_status_accepted": "Accepterad", + "fill_status_assigned": "Tilldelad", + "fill_status_checked_in": "Incheckad", + "fill_status_declined": "Avböjd", + "fill_status_demobilized": "Demobiliserad", + "fill_status_mobilized": "Mobiliserad", + "fill_status_released": "Frigiven", + "fill_status_requested": "Begärd", + "fill_status_returned": "Återvänt", + "fills": "Begäranden och tillsättningar", + "finalize": "Slutför", + "finalized": "Slutförd.", + "fix_fields": "Vissa fält behöver fortfarande åtgärdas.", + "forbidden": "Endast avdelningsadministratörer", + "granted": "Beviljad", + "hide_closed": "Dölj avslutade insatser", + "include_closed": "Visa avslutade insatser", + "last_error": "Senaste fel", + "last_read": "Senast läst", + "last_success": "Senaste lyckade", + "load_failed": "Posten kunde inte laddas.", + "loading": "Laddar poster…", + "location_kept": "Plats behållen", + "location_removed": "Plats borttagen", + "manage_on_web": "Kopplingar skapas, ändras och får sina behörigheter på webben.", + "minutes": "{{count}} min", + "needed_on": "Behövs", + "needs_attention": "Behöver dig", + "never_read": "Aldrig läst", + "new_record": "Ny post", + "no_assignments": "Inget är tilldelat dig.", + "no_attachments": "Inga bilagor ännu.", + "no_connectors": "Inga kopplingar", + "no_deployments": "Inga insatser", + "no_drafts": "Du har inga utkast.", + "no_fills": "Inga begäranden på denna beställning", + "no_recent": "Inga senaste poster.", + "no_reconciliation": "Inga avvikelser mellan källan och dina poster", + "no_returned": "Inget har återsänts till dig.", + "no_rows": "Inga rader ännu.", + "no_runs": "Inga körningar ännu", + "not_found": "Posten är inte tillgänglig.", + "not_granted": "Inte beviljad", + "not_sent_yet": "Väntar på att skickas", + "nothing_to_create": "Inget kan startas härifrån just nu.", + "offices": "Kontor", + "offline_capable": "Fungerar offline", + "online_only": "Detta formulär fylls i endast online.", + "online_only_badge": "Endast online", + "open_artifact": "Öppna källartefakt", + "open_record": "Öppna post", + "pending": "Ej skickad", + "poll_interval": "Avfrågningsintervall", + "preview": "Förhandsvisning", + "purpose_acknowledge": "Bekräfta denna post", + "purpose_attach": "Lägg till en bilaga", + "purpose_complete": "Slutför denna post", + "purpose_correct": "Rätta denna post", + "purpose_review": "Granska denna post", + "read_authority": "Läsbehörighet", + "read_feed_now": "Läs flödet nu", + "reading_feed": "Läser…", + "reason_app_disabled": "Poster är inte påslagna för den här appen i din avdelning.", + "reason_app_version_too_old": "Uppdatera appen för att använda poster.", + "reason_module_disabled": "Poster är inte tillgängliga för din avdelning.", + "reason_not_member": "Du är inte en aktiv medlem i denna avdelning.", + "reason_records_not_usable": "Din avdelning har inte aktiverat poster ännu.", + "recent": "Senaste", + "reconciliation": "Avstämning", + "reconciliation_count": "{{count}} att stämma av", + "reconciliation_hint": "Där källan och din post skiljer sig åt. Inget tillämpas automatiskt; en samordnare avgör på webben.", + "reconciliation_local_fill_missing_in_source": "Tillsättningen finns lokalt men inte i källan", + "reconciliation_source_closed_local_open": "Källan stängde beställningen, men tillsättningar är fortfarande öppna", + "reconciliation_source_released_local_not_returned": "Källan frigav beställningen, men en tillsättning har inte återvänt", + "reconciliation_source_request_missing_locally": "Begäran finns i källan men inte lokalt", + "reconciliation_source_status_ahead": "Källans status ligger före den lokala tillsättningen", + "reconciliation_source_status_behind": "Källans status ligger efter den lokala tillsättningen", + "refresh_catalog": "Uppdatera", + "requests_this_hour": "Begäranden denna timme", + "restricted": "Begränsad", + "returned": "Återsända till dig", + "run_counts": "Sedda {{seen}} · skapade {{created}} · ögonblicksbilder {{snapshots}} · begäranden {{requests}} · oförändrade {{unchanged}} · avvisade {{rejected}} · att stämma av {{conflicts}}", + "run_failed": "Flödet kunde inte läsas", + "run_finished": "Flödet läst {{outcome}}: {{created}} skapade, {{snapshots}} ögonblicksbilder, {{requests}} begäranden, {{conflicts}} att stämma av", + "run_log": "Körningslogg", + "run_not_ok": "Flödesläsningen slutade med {{outcome}}", + "run_outcome_disabled": "Inaktiverad", + "run_outcome_failed": "Misslyckad", + "run_outcome_ok": "OK", + "run_outcome_rate_limited": "Begärandegräns nådd", + "run_outcome_rejected": "Avvisad", + "run_trigger_inbound": "Skickad av källan", + "run_trigger_manual": "Manuell", + "run_trigger_poll": "Schemalagd", + "save": "Spara", + "save_and_send": "Spara och skicka", + "save_failed": "Det gick inte att spara.", + "save_locally": "Behåll på den här enheten", + "saved": "Sparat.", + "saved_locally": "Sparat på enheten. Skickas när du är online igen.", + "sign_statement": "Jag signerar denna post som författare", + "source": "Källa", + "source_says": "Källa:", + "submit_for_review": "Skicka för granskning", + "submitted": "Skickad för granskning.", + "take_photo": "Ta ett foto", + "terms": "Källans villkor", + "terms_acknowledged": "Bekräftade", + "terms_not_acknowledged": "Ännu inte bekräftade", + "title": "Poster", + "trainee": "Praktikant", + "unavailable": "Poster är inte tillgängliga i den här appen just nu.", + "unit_code": "Enhet", + "unsupported_definition": "Detta formulär använder ett fält som denna appversion inte kan fylla i. Uppdatera appen eller använd webbappen.", + "unsupported_field": "Appen kan inte visa detta fält ännu. Använd webbappen för att fylla i det.", + "untitled": "Namnlös", + "update_required": "Appuppdatering behövs", + "write_authority": "Skrivbehörighet", + "write_refused": "Nekad – inget skrivs tillbaka", + "your_record_says": "Din post:" + }, "roles": { "assignedElsewhere": "I en annan roll", "clearAssignment": "Rensa den aktuella tilldelningen", @@ -1117,6 +1731,17 @@ "version": "Version", "website": "Webbplats" }, + "site_info": { + "additional_contact": "Ytterligare kontakt", + "alert_notes": "Varningsanteckningar", + "empty": "Ingen objektinformation", + "empty_description": "Inga kontakter med insatsplan, risker, varningsanteckningar eller filer är kopplade till detta larm.", + "entrance": "Entré", + "files": "Objektfiler", + "load_failed": "Objektinformationen kunde inte laddas.", + "loading": "Laddar objektinformation...", + "primary_contact": "Huvudkontakt" + }, "status": { "add_note": "Lägg till anteckning", "both_destinations_enabled": "Kan svara på samtal eller stationer", @@ -1157,6 +1782,7 @@ "map": "Karta", "notes": "Anteckningar", "protocols": "Protokoll", + "records": "Poster", "routes": "Rutter", "settings": "Inställningar", "weather_alerts": "Väder" @@ -1277,5 +1903,80 @@ "unknown": "Okänd" } }, - "welcome": "Välkommen till Resgrid Unit app site" + "welcome": "Välkommen till Resgrid Unit app site", + "inventory": { + "title": "Förråd", + "titleFor": "Förråd · {{name}}", + "selectUnit": "Välj din enhet först.", + "unnamedLocation": "Fack", + "unnamedItem": "Artikel", + "countName": "Kontroll {{name}} {{date}}", + "notMigrated": "Förrådet håller på att sättas upp för er organisation.", + "disabled": "Förrådet är avstängt för er organisation.", + "noLocations": "Ingen utrustning är kopplad till den här enheten.", + "startCount": "Inventera", + "wholeUnit": "Hela enheten: {{name}}", + "pickLocation": "Var inventeringen görs", + "startHelp": "Räkna det som faktiskt finns på enheten. Avvikelser bokförs när inventeringen avslutas.", + "start": "Starta inventering", + "cannotCount": "Inventering kräver behörigheten Justera förråd för den här enheten.", + "counts": "Senaste inventeringar", + "count": "Inventering", + "empty": "Här finns inget.", + "serial": "Serienr {{serial}}", + "quantity": "× {{quantity}}", + "back": "Tillbaka", + "completeTitle": "Avsluta inventeringen?", + "completeBody": "{{count}} artiklar avviker från det förväntade. Avslutning bokför avvikelserna.", + "completeNoChanges": "Allt stämmer med det förväntade.", + "cancel": "Avbryt", + "complete": "Avsluta", + "cancelTitle": "Avbryta inventeringen?", + "cancelBody": "Inget av det som räknats bokförs.", + "keep": "Fortsätt räkna", + "cancelCount": "Avbryt inventering", + "progress": "{{counted}} av {{total}} räknade", + "differences": "{{count}} avvikelser", + "awaitingWitness": "En kontrollerad artikel ändrades; en andra person måste bevittna inventeringen på webben.", + "completed": "Inventeringen är klar.", + "expected": "Förväntat {{quantity}} {{unit}}", + "present": "Finns", + "missing": "Saknas", + "less": "En mindre", + "more": "En till", + "matches": "Stämmer", + "save": "Spara läget", + "countStatus": { + "0": "Pågår", + "1": "Väntar på vittne", + "2": "Klar", + "3": "Avbruten" + }, + "assetStatus": { + "0": "I tjänst", + "1": "Utlämnad", + "2": "På reparation", + "3": "Skadad", + "4": "Förlorad", + "5": "Förbrukad", + "6": "Avvecklad" + }, + "errors": { + "retry": "Kunde inte nå servern. Försök igen.", + "locked": "Lås upp skyddade uppgifter för att fortsätta.", + "PermissionRequired": "Du har inte behörighet att göra detta.", + "MembershipRequired": "Du är inte aktiv medlem i organisationen.", + "InventoryDisabled": "Förrådet är avstängt för er organisation.", + "MigrationRequired": "Förrådet håller på att sättas upp för er organisation.", + "CountScopeTooLarge": "För många artiklar för en inventering. Räkna ett fack i taget.", + "CountEmpty": "Det finns inget att räkna här.", + "CountSnapshotChanged": "Enhetens förråd ändrades medan du räknade. Avbryt och börja om.", + "CountStateConflict": "Inventeringen ändrades på en annan enhet. Öppna den igen.", + "ReturnAssetFirst": "En artikel som markerats saknad är fortfarande utlämnad. Återlämna den först på webben.", + "ProtectedDataRequired": "Lås upp skyddade uppgifter för att fortsätta.", + "CountAlreadyExists": "Inventeringen finns redan.", + "LocationUnavailable": "Platsen är inte tillgänglig.", + "InvalidCount": "Kontrollera de räknade värdena och försök igen." + } + } } diff --git a/src/translations/uk.json b/src/translations/uk.json index 2d8aead6..4fdff220 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -126,6 +126,12 @@ "title": "Примітки виклику" }, "call_detail": { + "activity_link": { + "auto": "Прив'язано автоматично", + "auto_hint": "Надіслано без виклику; Resgrid прив'язав його за попереднім статусом або направленням.", + "inferred": "Виведено", + "inferred_hint": "Встановлено без пункту призначення підрозділом або особою, направленими на цей виклик, під час роботи над ним." + }, "address": "Адреса", "call_location": "Місце виклику", "close_call": "Закрити виклик", @@ -201,6 +207,7 @@ "dispatched": "Відправлено", "info": "Інфо", "protocols": "Протоколи", + "site": "Про об'єкт", "timeline": "Активність" }, "timestamp": "Мітка часу", @@ -228,6 +235,9 @@ "contact_name_placeholder": "Введіть ім'я контакту", "contact_phone": "Телефон контакту", "contact_phone_placeholder": "Введіть телефон контакту", + "contact_picker": { + "linked": "Пов'язаний контакт: {{name}}" + }, "coordinates": "GPS координати", "coordinates_found": "Координати знайдено та адресу оновлено", "coordinates_geocoding_error": "Не вдалося отримати адресу для координат, але місцезнаходження встановлено на карті", @@ -445,6 +455,206 @@ "type_sector_rotation": "Ротація секторів", "type_unit": "Підрозділ" }, + "checklists": { + "errors": { + "conflict": "Цю перевірку змінено на сервері. Локальну роботу збережено; перегляньте її перед повторною спробою.", + "denied": "Доступ недоступний. Зашифровану локальну роботу збережено.", + "locked": "Розблокуйте захищені дані, щоб продовжити.", + "offline": "Без мережі: показано безпечно збережені перевірки.", + "retry": "Не вдалося завершити синхронізацію. Локальну роботу збережено.", + "storage_full": "Досягнуто локального обмеження чернеток або доказів.", + "validation": "Перевірте обов’язкові відповіді, дати, примітки та докази й повторіть спробу." + }, + "labels": { + "Actions": "Дії", + "Active": "Активний", + "AnchorMonth": "Опорний місяць", + "Annual": "Щороку", + "Answer": "Відповідь", + "Answered": "Надано відповідь", + "AssetUnavailable": "Обладнання із серійним номером недоступне.", + "Assignment": "Призначено для", + "AssignmentAutomatic": "Автоматичне призначення", + "AssignmentPermission": "Вас не призначено виконувати цю перевірку.", + "AssignmentType1": "Особа", + "AssignmentType2": "Роль персоналу", + "AssignmentType3": "Група або станція", + "AssignmentType4": "Екіпаж підрозділу", + "AssignmentUnavailable": "Виберіть доступне призначення в цій організації.", + "Attest": "Підтвердити та завершити", + "Attestation": "Заява про незалежну перевірку", + "Author": "Автор", + "AwaitingWitness": "Очікує на свідка", + "Back": "Назад", + "CalendarInclude": "Перевірки готовності", + "CalendarRange": "Виберіть період календаря тривалістю до 93 днів.", + "Cancelled": "Скасовано", + "Category": "Категорія", + "Checkbox": "Прапорець", + "Checked": "Позначено", + "ChecklistCompleted": "Контрольний список завершено", + "ChecklistFailed": "Контрольний список не пройдено", + "ChecklistMissed": "Контрольний список не виконано", + "ChecklistOccurrenceSkipped": "Пропуск запланованої перевірки обґрунтовано", + "ChecklistScheduleChanged": "Розклад перевірок змінено", + "Checklists": "Контрольні списки", + "Checks": "Перевірки", + "Choose": "Виберіть", + "Critical": "Критичний", + "Daily": "Щодня", + "Date": "Дата", + "DayOfMonth": "День місяця", + "DeleteDraft": "Видалити чернетку", + "Department": "Підрозділ", + "Draft": "Чернетка", + "DraftGuidance": "Збережіть чернетку, а потім опублікуйте її. Наявні виконання зберігають початкову версію. Не вводьте дані пацієнтів.", + "DueChecks": "Перевірки до виконання", + "DueGuidance": "Майбутні перевірки на сім днів і незавершені перевірки. Прострочені перевірки все ще можна виконати.", + "EditChecklist": "Редактор списків", + "EditDraft": "Редагувати чернетку", + "EditSchedule": "Редагувати розклад", + "Evidence": "Доказ", + "ExcuseCheck": "Обґрунтувати пропуск", + "Export": "Експортувати JSON", + "Facility": "Об’єкт", + "Fail": "Не пройдено", + "FailNote": "Невдала перевірка вимагає примітки.", + "Failed": "Не пройдено", + "Free": "Списки безкоштовні для всіх підрозділів.", + "Frequency": "Періодичність", + "Guidance": "Перед використанням адаптуйте шаблони до обладнання, процедур виробника та місцевих вимог.", + "History": "Історія", + "InProgress": "Триває", + "Incomplete": "Незавершено", + "InventoryAsset": "Обладнання із серійним номером", + "Item": "Пункт", + "Maintenance": "Технічне обслуговування та наряди на роботи (Readiness Pro)", + "Missed": "Не виконано", + "MonthGuidance": "Для щомісячних або рідших перевірок. У коротких місяцях використовується останній день. Опорний місяць визначає щоквартальні, піврічні та щорічні перевірки.", + "Monthly": "Щомісяця", + "Name": "Назва", + "NewChecklist": "Новий список", + "NewSchedule": "Новий розклад", + "Next": "Наступна", + "No": "Ні", + "NoDefinitions": "На цій сторінці немає списків.", + "NoDueChecks": "У цьому списку немає перевірок.", + "NoResults": "Відповідних шаблонів не знайдено.", + "NoSchedules": "Розкладів ще немає.", + "NoScore": "Немає застосовної оцінки", + "Note": "Примітка", + "OccurrenceUnavailable": "Ця перевірка недоступна або вже змінена.", + "OpenCheck": "Відкрити перевірку", + "Optional": "Необов’язково", + "Other": "Інше", + "Pass": "Пройдено", + "Passed": "Пройдено", + "PerShift": "Кожної зміни", + "Personnel": "Персонал", + "Photo": "Фото", + "Preview": "Перегляд шаблону", + "Previous": "Попередня", + "Print": "Друк", + "PublishDraft": "Опублікувати чернетку", + "PublishGuidance": "Публікація створює незмінну версію для нових виконань.", + "Published": "Опубліковано", + "PublishedVersion": "Опублікована версія", + "Quantity": "Кількість", + "Quarterly": "Щокварталу", + "ReminderBefore": "Хвилини до кінцевого терміну (0 вимикає)", + "ReminderDigest": "Об’єднувати нагадування у зведення", + "ReminderEnabled": "Увімкнути нагадування про контрольні списки", + "ReminderEscalate": "Сповістити адміністраторів підрозділу після такої кількості хвилин прострочення (порожнє поле вимикає)", + "ReminderFixedTime": "Час щоденного зведення (часовий пояс організації; замінює початок зміни)", + "ReminderHelp": "Нагадування стосуються перевірок, що починаються після їх увімкнення. Повідомлення містять посилання для входу та враховують налаштування сповіщень кожної людини. Зведення об’єднують нагадування, що очікують на надсилання, за одержувачем.", + "ReminderMessage": "Доступні оновлення контрольних списків. Увійдіть, щоб переглянути свої перевірки.", + "ReminderMissed": "Сповіщати відповідальний персонал про пропущені перевірки", + "ReminderSettings": "Нагадування про контрольні списки", + "ReminderShiftStart": "Надсилати зведення на початку зміни", + "ReminderTimeValidation": "Введіть один час у форматі HH:mm.", + "ReminderTitle": "Нагадування про контрольні списки", + "ReminderValidation": "Вкажіть інтервал нагадування від 0 до 10080 хвилин та інтервал сповіщення адміністраторів від 1 до 43200 хвилин або залиште другий порожнім.", + "Remove": "Видалити", + "ReportedLocation": "Заявлене місцезнаходження", + "Required": "Обов'язково", + "Result": "Результат", + "Retire": "Вивести з використання", + "Retired": "Виведено з використання", + "RunGuidance": "Дайте відповіді на всі застосовні пункти. Для відповіді «Не застосовується» потрібне пояснення. Збережіть хід виконання, щоб продовжити пізніше. Подані відповіді й підтвердження не можна змінювати.", + "SaveDraft": "Зберегти чернетку", + "SaveProgress": "Зберегти хід виконання", + "SaveReminders": "Зберегти налаштування нагадувань", + "SaveSchedule": "Зберегти розклад", + "ScheduleEnabled": "Розклад увімкнено", + "ScheduleEndDate": "Дата завершення (необов’язково)", + "ScheduleNotes": "Примітки до розкладу", + "SchedulePaused": "Призупинено", + "SchedulePermission": "Ви не можете керувати розкладами перевірок.", + "ScheduleRequiresPublished": "Опублікуйте контрольний список перед плануванням.", + "ScheduleStartDate": "Дата початку", + "ScheduleTimeGuidance": "Час відповідає цьому поясу. Пропущений час переноситься вперед; повторений час виконується лише один раз.", + "ScheduleUnavailable": "Цей розклад недоступний.", + "ScheduleValidation": "Перевірте дати, час, об’єкт і періодичність розкладу.", + "ScheduleVersionGuidance": "Збереження закріплює поточну опубліковану версію. Зміни замінюють майбутні нерозпочаті перевірки; призупинення скасовує нерозпочаті перевірки.", + "ScheduleWorkshift": "Зміна для перевірок кожної зміни", + "Scheduled": "Заплановано", + "ScheduledFor": "Заплановано на", + "Schedules": "Розклади", + "Score": "Оцінка", + "Search": "Пошук", + "SearchLabel": "Знайти шаблон", + "SelectTarget": "Виберіть ціль", + "SemiAnnual": "Кожні шість місяців", + "Signature": "Підпис", + "SkipReason": "Причина обґрунтованого пропуску", + "SkipReasonRequired": "Укажіть причину довжиною до 4000 символів.", + "Skipped": "Пропуск обґрунтовано", + "StartRun": "Розпочати список", + "Started": "Розпочато", + "State": "Стан", + "SubmitRun": "Подати список", + "Submitted": "Подано", + "Target": "Ціль", + "Templates": "Шаблони списків", + "TimeZone": "Часовий пояс", + "TimesOfDay": "Місцевий час", + "TimesOfDayGuidance": "Введіть до восьми значень часу у 24-годинному форматі через кому.", + "Unanswered": "Без відповіді", + "Unchecked": "Не позначено", + "Unit": "Одиниця техніки", + "Units": "Одиниці", + "Version": "Версія", + "Weekly": "Щотижня", + "WeeklyDays": "Дні щотижневих перевірок", + "WindowEnds": "Кінець терміну", + "WindowMinutes": "Термін виконання (хвилини)", + "Witness": "Автентифікований автор та інший автентифікований учасник мають незалежно перевірити результат.", + "WitnessInstructions": "Інший автентифікований учасник має незалежно перевірити подані відповіді й докази. Поділіться з ним посиланням на цю сторінку.", + "WitnessLabel": "Свідок", + "WorkshiftFallback": "Використовувати місцевий час щодня", + "WorkshiftGuidance": "Лише для перевірок кожної зміни. Вибрана зміна використовує записані дати початку; інакше місцевий час застосовується щодня.", + "Yes": "Так" + }, + "ui": { + "calendar": "Календар", + "clearSignature": "Очистити підпис", + "close": "Закрити", + "confirm": "Замінити", + "editLocal": "Редагувати локальну чернетку", + "localDrafts": "Локальні чернетки", + "notApplicable": "Не застосовується", + "prepareOffline": "Підготувати для роботи без мережі", + "queued": "Безпечно збережено на пристрої; очікування синхронізації.", + "reason": "Причина", + "refresh": "Оновити", + "removeImage": "Видалити зображення", + "replaceLocal": "Замінити локальні відповіді та зображення, що очікують надсилання, версією із сервера?", + "retry": "Повторити синхронізацію", + "takePhoto": "Зробити фото", + "unlock": "Розблокувати захищені дані", + "useServer": "Використати версію із сервера" + } + }, "common": { "add": "Додати", "back": "Назад", @@ -536,6 +746,12 @@ "expires": "Закінчується", "facebook": "Facebook", "faxPhone": "Факс", + "files": { + "download_failed": "Не вдалося завантажити файл.", + "empty": "До цього контакту не додано файлів.", + "loading": "Завантаження файлів...", + "share_unavailable": "Поширення недоступне на цьому пристрої." + }, "formError": "Виправте помилки у формі", "homePhone": "Домашній телефон", "identification": "Ідентифікація", @@ -557,6 +773,48 @@ "otherInfo": "Інша інформація", "person": "Особа", "phone": "Телефон", + "preplan": { + "access": "Доступ", + "access_notes": "Нотатки про доступ", + "alarm_company": "Охоронна компанія", + "alarm_company_phone": "Телефон охоронної компанії", + "alarm_panel": "Панель сигналізації", + "alert_flag": "Тривога", + "construction": "Конструкція · дах · використання", + "electric_shutoff": "Відключення електрики", + "emergency_contact": "Екстрений контакт", + "emergency_contact_phone": "Телефон екстреного контакту", + "gas_shutoff": "Відключення газу", + "gate_code": "Код воріт", + "general_hazard_notes": "Загальні нотатки про небезпеки", + "general_hazards": "Небезпеки та тактика", + "hazards": "Небезпеки об'єкта", + "hazmat_on_site": "Небезпечні матеріали на об'єкті", + "knox_box": "Ключниця", + "last_reviewed": "Останній перегляд", + "loading": "Завантаження плану...", + "location": "Розташування", + "nearest_hydrant": "Найближчий гідрант", + "never_reviewed": "Ніколи не переглядався", + "next_review": "Наступний перегляд", + "none": "Для цього контакту не зафіксовано попередній план дій.", + "occupancy": "Конструкція та використання", + "occupancy_hours": "Години роботи", + "occupancy_notes": "Нотатки про використання", + "occupant_load": "Кількість людей", + "occupants_needing_assistance": "Людям може знадобитися допомога під час евакуації", + "on_site_contacts": "Контакти на об'єкті", + "required_fire_flow": "Необхідна витрата води", + "review_overdue": "Дата перегляду плану минула.", + "secondary_contact": "Додатковий контакт", + "secondary_contact_phone": "Телефон додаткового контакту", + "tactical_summary": "Тактичний підсумок", + "utilities": "Відключення комунікацій", + "utility_notes": "Нотатки про комунікації", + "water_shutoff": "Відключення води", + "water_supply": "Водопостачання", + "water_supply_notes": "Нотатки про водопостачання" + }, "public": "Публічний", "required": "Обов'язково", "save": "Зберегти контакт", @@ -569,7 +827,9 @@ "systemInformation": "Системна інформація", "tabs": { "details": "Деталі", - "notes": "Примітки" + "files": "Файли", + "notes": "Примітки", + "preplan": "План" }, "threads": "Threads", "title": "Контакти", @@ -578,7 +838,13 @@ "unknown_person": "Невідома особа", "visibility": "Видимість", "website": "Веб-сайт", - "zip": "Поштовий індекс" + "zip": "Поштовий індекс", + "withheld": "Приховано {{count}} захищених даних.", + "categoryLabel": "Категорія: {{name}}", + "physicalAddress": "Фактична адреса", + "mailingAddress": "Поштова адреса", + "hasGeofence": "Для цього місця на карті позначено геозону.", + "customFields": "Власні поля" }, "data_protection": { "conceal": "Приховати знову", @@ -813,11 +1079,193 @@ "select_all": "Вибрати все", "selected_count": "Вибрано: {{count}}", "title": "Сповіщення", - "unable_to_load": "Не вдалося завантажити сповіщення" + "unable_to_load": "Не вдалося завантажити сповіщення", + "detail_title": "Сповіщення", + "additional_info": "Додаткова інформація", + "view_call": "Переглянути виклик", + "view_chat": "Переглянути чат", + "open_reference": "Відкрити" }, "onboarding": { "message": "Ласкаво просимо до додатку Resgrid Unit" }, + "operations": { + "all": "Усі", + "back": "Назад", + "disabled": "Розгортання не ввімкнено для цього підрозділу.", + "entryType": { + "0": "Розгортання", + "1": "Очікування", + "2": "Переїзд" + }, + "errors": { + "denied": "У вас немає доступу до цього розгортання.", + "disabled": "Цю функцію вимкнено для вашого підрозділу.", + "locked": "Розблокуйте захищені дані, щоб продовжити.", + "retry": "Не вдалося з’єднатися з сервером. Спробуйте ще раз.", + "validation": "Сервер відхилив зміну.", + "timereports_subject_covered": "Цей екіпаж або особа вже мають час в іншому звіті за цей день.", + "timereports_date_exists": "Звіт для цього екіпажу або особи за той день уже існує.", + "timereports_scope_invalid": "Цей екіпаж або особа не активні в цьому розгортанні.", + "timereports_locked": "Цей звіт більше не можна змінити.", + "timereports_time_invalid": "Вводьте час у форматі HH:mm.", + "deployments_closed": "Це розгортання закрите.", + "expenses_receipt_invalid": "Не вдалося прочитати чек.", + "deployments_attachment_too_large": "Чек завеликий." + }, + "financeMode": { + "0": "Оперативне", + "1": "Відшкодування витрат", + "2": "Оплачуване" + }, + "issues": { + "end_before_start": "Час завершення має бути пізніше за час початку.", + "missing_subject": "Виберіть, кого або чого стосується цей запис.", + "missing_time": "Введіть час початку та завершення.", + "overlap": "Цей запис перетинається з іншим для тієї самої особи або підрозділу.", + "subject_outside_report_scope": "Цей запис стосується когось поза екіпажем або особою цього звіту.", + "subject_on_other_report": "Ця особа або підрозділ уже має час в іншому звіті за той самий день.", + "subject_not_on_roster": "Цей запис стосується когось, кого немає у складі.", + "entries_overlap": "Два записи тієї самої особи або підрозділу перекриваються.", + "no_entries": "Додайте принаймні один запис.", + "break_rule": "Для довгої зміни не записано перерви.", + "long_travel": "Більше переїздів, ніж дозволено за день; потрібне затвердження.", + "outside_report_date": "Цей запис виходить за межі дати звіту." + }, + "mars": { + "counts": "{{errors}} помилок · {{warnings}} попереджень", + "draft": "Створити чернетку F-42 зі звітів про час", + "f42": "F-42", + "help": "F-42 формується з поданих звітів про час і перевіряється за правилами порталу CAL OES. Подання залишається ручним аудитованим кроком у вебверсії.", + "none": "Для цього розгортання ще немає елементів MARS.", + "notReady": "Не готово для порталу", + "ready": "Готово для порталу", + "record": "MARS {{id}} · {{status}}", + "title": "F-42", + "validate": "Перевірити" + }, + "noNumbers": "Немає номерів інциденту чи запиту", + "none": "Розгортань немає.", + "open": "Відкриті", + "phase": { + "0": "Мобілізація", + "1": "Очікування", + "2": "Інцидент", + "3": "Повернення" + }, + "reportStatus": { + "0": "Чернетка", + "1": "Подано", + "2": "Затверджено", + "3": "Виставлено рахунок", + "4": "Скасовано" + }, + "roster": "{{units}} підрозділів · {{personnel}} осіб", + "status": { + "0": "Заплановано", + "1": "Очікування", + "2": "Активне", + "3": "Демобілізація", + "4": "Завершено", + "5": "Скасовано" + }, + "time": { + "accommodation": "Проживання надає агентство", + "addEntry": "Додати запис", + "from": "З", + "hours": "{{hours}} год", + "meals": "Харчування надає агентство", + "noEntries": "Записів ще немає.", + "noReport": "Немає звіту про час за цей день.", + "notes": "Нотатки", + "remove": "Видалити", + "report": "Звіт #{{number}}", + "save": "Зберегти записи", + "start": "Почати звіт про час", + "subject": "Хто або що", + "submit": "Подати звіт", + "title": "Час", + "to": "До", + "total": "Разом {{hours}} год", + "type": "Тип запису", + "unpaidBreak": "Неоплачувана перерва (хв)", + "crewReport": "Звіт про час екіпажу #{{number}} · {{name}}", + "individualReport": "Звіт про час #{{number}} · {{name}}", + "noCrewReport": "Немає звіту про час екіпажу {{name}} за цей день.", + "coveredBy": "Цей час уже є у звіті #{{number}} за цей день.", + "startCrew": "Почати звіт про час екіпажу", + "readOnly": "Ви можете переглядати цей звіт, але не змінювати його.", + "nextDay": "Закінчується наступного дня", + "applyToCrew": "Застосувати до екіпажу", + "approve": "Затвердити звіт", + "dayReports": "Звіти за цей день", + "awaitingApproval": "Очікують затвердження ({{count}})", + "notOnRoster": "Ви не входите до складу чи екіпажу цього розгортання." + }, + "title": "Розгортання", + "usage": { + "add": "Додати показник", + "endOdometer": "Одометр наприкінці", + "engineHours": "Мотогодини", + "fuel": "Пальне", + "needsReview": "Потребує перевірки", + "phase": "Фаза", + "saved": "Показник збережено.", + "startOdometer": "Одометр на початку", + "title": "Використання", + "unit": "Підрозділ" + }, + "crewOf": "Екіпаж {{names}}", + "previousDay": "Попередній день", + "nextDay": "Наступний день", + "scope": { + "deployment": "Усе розгортання", + "mine": "Мій час", + "crew": "Екіпаж: {{name}}", + "individual": "{{name}}" + }, + "sign": { + "title": "Підписи", + "crewBossSigned": "Підписано командиром екіпажу {{when}}", + "crewBossPending": "Командир екіпажу ще не підписав.", + "customerSigned": "Підписант установи: {{name}}", + "customerPending": "Підпис установи не записано.", + "asCrewBoss": "Підписати як командир екіпажу", + "customerName": "Ім'я підписанта установи", + "recordCustomer": "Записати підпис установи" + }, + "expenses": { + "title": "Витрати", + "type": { + "0": "Добові на харчування", + "1": "Проживання", + "2": "Приватне проживання", + "3": "Пором", + "4": "Пальне", + "5": "Поповнення запасів", + "6": "Інше" + }, + "typeLabel": "Тип витрати", + "amount": "Сума ({{currency}})", + "description": "Опис", + "city": "Місто", + "takePhoto": "Зробити фото", + "choosePhoto": "Вибрати фото", + "receiptAttached": "Фото чека додано.", + "save": "Додати витрату", + "add": "Нова витрата", + "noneToday": "Немає витрат за цей день.", + "otherDays": "Інші дні", + "hasReceipt": "чек", + "remove": "Видалити", + "removeTitle": "Видалити витрату?", + "removeBody": "Це видалить витрату та її чек із розгортання.", + "cancel": "Скасувати", + "saved": "Витрату збережено.", + "amountInvalid": "Введіть суму, наприклад 42.50.", + "photoDenied": "Дозвольте доступ до камери або фото, щоб додати чек." + } + }, "protocols": { "details": { "close": "Закрити", @@ -846,6 +1294,172 @@ "unknown_type_warning": "Цей тип сповіщення не розпізнано. Зверніться до служби підтримки, якщо повідомлення продовжує з'являтися.", "view_call": "Переглянути виклик" }, + "records": { + "add_row": "Додати рядок", + "agencies": "Агенції", + "agreement": "Угода", + "assigned": "Призначено вам", + "attachment_failed": "Не вдалося надіслати вкладення.", + "attachment_file_missing": "Файла більше немає на цьому пристрої.", + "attachment_unreadable": "Не вдалося прочитати файл.", + "attachment_will_resume": "Призупинено. Продовжиться з місця зупинки.", + "attachments": "Вкладення", + "attachments_hint": "Вкладення додаються після збереження запису.", + "attestation": "Я підтверджую, що цей запис повний і точний", + "back": "Назад", + "busy": "Читання вже виконується", + "camera_denied": "Камера недоступна без дозволу.", + "cancel": "Скасувати", + "captured_on": "Зафіксовано", + "choose": "Виберіть…", + "choose_file": "Вибрати файл", + "choose_photo": "Вибрати фото", + "closed_out_on": "Закрито", + "closeout_notes": "Нотатки закриття", + "conflict_app_version": "Записи не ввімкнено для цієї версії додатка.", + "conflict_definition_retired": "Цю форму виведено з ужитку. Почніть знову з поточного списку.", + "conflict_etag": "Хтось інший змінив цей запис. Перезавантажте його й застосуйте зміни знову.", + "conflict_permission": "Ви більше не маєте доступу до цього запису.", + "conflict_protected_data": "Ця форма потребує активного з'єднання та вашого доступу до захищених даних.", + "conflict_scope": "Ваш доступ змінився, тому запис перезавантажено.", + "connector_disabled": "Вимкнено", + "connector_maintained": "Підтримується конектором", + "connector_not_ready": "Не готовий", + "connector_ready": "Готовий", + "connectors": "Зовнішні конектори", + "connectors_hint": "Кожен конектор читає одну систему замовлень на її умовах, із зашифрованими обліковими даними та лімітом запитів. Право запису відхиляється.", + "connectors_home_hint": "Канали систем замовлень і те, що потребує звірки", + "cost_code": "Код витрат", + "deployment_status_closed_out": "Закрито", + "deployment_status_mobilized": "Мобілізовано", + "deployment_status_open": "Відкрите", + "deployment_status_released": "Звільнено", + "deployments": "Розгортання", + "deployments_hint": "Система замовлень залишається авторитетною. Заповнення змінює координатор у вебі; тут нічого не змінюється.", + "deployments_home_hint": "Зовнішні замовлення ресурсів, які заповнює ваш підрозділ", + "drafts": "Чернетки", + "field_required": "Це поле обов'язкове.", + "fill_status_accepted": "Прийнято", + "fill_status_assigned": "Призначено", + "fill_status_checked_in": "Зареєстровано", + "fill_status_declined": "Відхилено", + "fill_status_demobilized": "Демобілізовано", + "fill_status_mobilized": "Мобілізовано", + "fill_status_released": "Звільнено", + "fill_status_requested": "Запитано", + "fill_status_returned": "Повернуто", + "fills": "Запити та заповнення", + "finalize": "Завершити", + "finalized": "Завершено.", + "fix_fields": "Деякі поля ще потребують уваги.", + "forbidden": "Лише адміністратори підрозділу", + "granted": "Надано", + "hide_closed": "Приховати закриті розгортання", + "include_closed": "Показати закриті розгортання", + "last_error": "Остання помилка", + "last_read": "Останнє читання", + "last_success": "Останній успіх", + "load_failed": "Не вдалося завантажити запис.", + "loading": "Завантаження записів…", + "location_kept": "Розташування збережено", + "location_removed": "Розташування видалено", + "manage_on_web": "Конектори створюють, змінюють і надають їм облікові дані у вебі.", + "minutes": "{{count}} хв", + "needed_on": "Потрібно", + "needs_attention": "Потребує вашої уваги", + "never_read": "Ніколи не читалося", + "new_record": "Новий запис", + "no_assignments": "Вам нічого не призначено.", + "no_attachments": "Вкладень ще немає.", + "no_connectors": "Немає конекторів", + "no_deployments": "Немає розгортань", + "no_drafts": "У вас немає чернеток.", + "no_fills": "Немає запитів у цьому замовленні", + "no_recent": "Немає останніх записів.", + "no_reconciliation": "Розбіжностей між джерелом і вашими записами немає", + "no_returned": "Вам нічого не повернуто.", + "no_rows": "Рядків ще немає.", + "no_runs": "Запусків ще немає", + "not_found": "Цей запис недоступний.", + "not_granted": "Не надано", + "not_sent_yet": "Очікує надсилання", + "nothing_to_create": "Зараз звідси нічого не можна розпочати.", + "offices": "Офіси", + "offline_capable": "Працює офлайн", + "online_only": "Цю форму заповнюють лише онлайн.", + "online_only_badge": "Лише онлайн", + "open_artifact": "Відкрити артефакт джерела", + "open_record": "Відкрити запис", + "pending": "Не надіслано", + "poll_interval": "Інтервал опитування", + "preview": "Попередній перегляд", + "purpose_acknowledge": "Підтвердіть цей запис", + "purpose_attach": "Додайте вкладення", + "purpose_complete": "Заповніть цей запис", + "purpose_correct": "Виправте цей запис", + "purpose_review": "Перевірте цей запис", + "read_authority": "Право читання", + "read_feed_now": "Прочитати канал зараз", + "reading_feed": "Читання…", + "reason_app_disabled": "Записи не ввімкнено для цього додатка у вашому підрозділі.", + "reason_app_version_too_old": "Оновіть додаток, щоб користуватися записами.", + "reason_module_disabled": "Записи недоступні для вашого підрозділу.", + "reason_not_member": "Ви не є активним членом цього підрозділу.", + "reason_records_not_usable": "Ваш підрозділ ще не активував записи.", + "recent": "Останні", + "reconciliation": "Звірка", + "reconciliation_count": "{{count}} до звірки", + "reconciliation_hint": "Де джерело і ваш запис розходяться. Нічого не застосовується автоматично; координатор вирішує у вебі.", + "reconciliation_local_fill_missing_in_source": "Заповнення існує локально, але не в джерелі", + "reconciliation_source_closed_local_open": "Джерело закрило замовлення, але заповнення ще відкриті", + "reconciliation_source_released_local_not_returned": "Джерело звільнило замовлення, але заповнення ще не повернулося", + "reconciliation_source_request_missing_locally": "Запит існує в джерелі, але не локально", + "reconciliation_source_status_ahead": "Статус джерела випереджає локальне заповнення", + "reconciliation_source_status_behind": "Статус джерела відстає від локального заповнення", + "refresh_catalog": "Оновити", + "requests_this_hour": "Запитів цієї години", + "restricted": "Обмежено", + "returned": "Повернуто вам", + "run_counts": "Побачено {{seen}} · створено {{created}} · знімки {{snapshots}} · запити {{requests}} · без змін {{unchanged}} · відхилено {{rejected}} · до звірки {{conflicts}}", + "run_failed": "Не вдалося прочитати канал", + "run_finished": "Канал прочитано {{outcome}}: {{created}} створено, {{snapshots}} знімків, {{requests}} запитів, {{conflicts}} до звірки", + "run_log": "Журнал запусків", + "run_not_ok": "Читання каналу завершилося з результатом {{outcome}}", + "run_outcome_disabled": "Вимкнено", + "run_outcome_failed": "Помилка", + "run_outcome_ok": "Успішно", + "run_outcome_rate_limited": "Ліміт запитів", + "run_outcome_rejected": "Відхилено", + "run_trigger_inbound": "Надіслано джерелом", + "run_trigger_manual": "Вручну", + "run_trigger_poll": "За розкладом", + "save": "Зберегти", + "save_and_send": "Зберегти й надіслати", + "save_failed": "Не вдалося зберегти.", + "save_locally": "Зберегти на цьому пристрої", + "saved": "Збережено.", + "saved_locally": "Збережено на пристрої. Надішлеться, коли ви знову будете онлайн.", + "sign_statement": "Я підписую цей запис як автор", + "source": "Джерело", + "source_says": "Джерело:", + "submit_for_review": "Надіслати на перевірку", + "submitted": "Надіслано на перевірку.", + "take_photo": "Зробити фото", + "terms": "Умови джерела", + "terms_acknowledged": "Підтверджено", + "terms_not_acknowledged": "Ще не підтверджено", + "title": "Записи", + "trainee": "Стажер", + "unavailable": "Записи наразі недоступні в цьому додатку.", + "unit_code": "Одиниця", + "unsupported_definition": "Ця форма використовує поле, яке ця версія додатка не може заповнити. Оновіть додаток або скористайтеся вебдодатком.", + "unsupported_field": "Цей додаток ще не може показати це поле. Заповніть його у вебдодатку.", + "untitled": "Без назви", + "update_required": "Потрібне оновлення додатка", + "write_authority": "Право запису", + "write_refused": "Відхилено — нічого не записується назад", + "your_record_says": "Ваш запис:" + }, "roles": { "assignedElsewhere": "В іншій ролі", "clearAssignment": "Очистити поточне призначення", @@ -1117,6 +1731,17 @@ "version": "Версія", "website": "Веб-сайт" }, + "site_info": { + "additional_contact": "Додатковий контакт", + "alert_notes": "Тривожні нотатки", + "empty": "Немає інформації про об'єкт", + "empty_description": "Жоден контакт, пов'язаний із цим викликом, не має плану, небезпек, тривожних нотаток чи файлів.", + "entrance": "Вхід", + "files": "Файли об'єкта", + "load_failed": "Не вдалося завантажити інформацію про об'єкт.", + "loading": "Завантаження інформації про об'єкт...", + "primary_contact": "Основний контакт" + }, "status": { "add_note": "Додати примітку", "both_destinations_enabled": "Може реагувати на виклики або станції", @@ -1157,6 +1782,7 @@ "map": "Карта", "notes": "Примітки", "protocols": "Протоколи", + "records": "Записи", "routes": "Маршрути", "settings": "Налаштування", "weather_alerts": "Погода" @@ -1277,5 +1903,80 @@ "unknown": "Невідомий" } }, - "welcome": "Ласкаво просимо до додатку Resgrid Unit" + "welcome": "Ласкаво просимо до додатку Resgrid Unit", + "inventory": { + "title": "Майно", + "titleFor": "Майно · {{name}}", + "selectUnit": "Спершу виберіть свій підрозділ.", + "unnamedLocation": "Відсік", + "unnamedItem": "Позиція", + "countName": "Перевірка {{name}} {{date}}", + "notMigrated": "Облік майна налаштовується для вашої організації.", + "disabled": "Облік майна вимкнено для вашої організації.", + "noLocations": "За цим підрозділом не закріплено майна.", + "startCount": "Перерахувати майно", + "wholeUnit": "Увесь підрозділ: {{name}}", + "pickLocation": "Де рахувати", + "startHelp": "Рахуйте те, що фактично є на техніці. Розбіжності буде проведено після завершення.", + "start": "Почати перерахунок", + "cannotCount": "Для перерахунку потрібен дозвіл на коригування майна цього підрозділу.", + "counts": "Останні перерахунки", + "count": "Перерахунок майна", + "empty": "Тут нічого немає.", + "serial": "Серійний № {{serial}}", + "quantity": "× {{quantity}}", + "back": "Назад", + "completeTitle": "Завершити перерахунок?", + "completeBody": "{{count}} позицій відрізняються від очікуваного. Завершення проведе розбіжності.", + "completeNoChanges": "Усе відповідає очікуваному.", + "cancel": "Скасувати", + "complete": "Завершити", + "cancelTitle": "Скасувати цей перерахунок?", + "cancelBody": "Нічого з пораховано не буде проведено.", + "keep": "Рахувати далі", + "cancelCount": "Скасувати перерахунок", + "progress": "Пораховано {{counted}} з {{total}}", + "differences": "{{count}} розбіжностей", + "awaitingWitness": "Змінилася підконтрольна позиція; друга особа має засвідчити перерахунок у вебі.", + "completed": "Перерахунок завершено.", + "expected": "Очікується {{quantity}} {{unit}}", + "present": "Є", + "missing": "Відсутнє", + "less": "На одну менше", + "more": "На одну більше", + "matches": "Збігається", + "save": "Зберегти прогрес", + "countStatus": { + "0": "Триває", + "1": "Очікує свідка", + "2": "Завершено", + "3": "Скасовано" + }, + "assetStatus": { + "0": "У службі", + "1": "Видано", + "2": "У ремонті", + "3": "Пошкоджено", + "4": "Втрачено", + "5": "Використано", + "6": "Списано" + }, + "errors": { + "retry": "Не вдалося зв'язатися із сервером. Спробуйте ще раз.", + "locked": "Розблокуйте захищені дані, щоб продовжити.", + "PermissionRequired": "У вас немає дозволу на цю дію.", + "MembershipRequired": "Ви не є активним членом цієї організації.", + "InventoryDisabled": "Облік майна вимкнено для вашої організації.", + "MigrationRequired": "Облік майна налаштовується для вашої організації.", + "CountScopeTooLarge": "Забагато позицій для одного перерахунку. Рахуйте по одному відсіку.", + "CountEmpty": "Тут нічого рахувати.", + "CountSnapshotChanged": "Майно підрозділу змінилося під час перерахунку. Скасуйте й почніть знову.", + "CountStateConflict": "Цей перерахунок змінено на іншому пристрої. Відкрийте його знову.", + "ReturnAssetFirst": "Позицію, позначену відсутньою, досі видано комусь. Спершу оформіть повернення у вебі.", + "ProtectedDataRequired": "Розблокуйте захищені дані, щоб продовжити.", + "CountAlreadyExists": "Такий перерахунок уже існує.", + "LocationUnavailable": "Це місце недоступне.", + "InvalidCount": "Перевірте пораховані значення та спробуйте ще раз." + } + } } diff --git a/src/types/notification.ts b/src/types/notification.ts index 118bc7ab..3c3113e3 100644 --- a/src/types/notification.ts +++ b/src/types/notification.ts @@ -6,7 +6,7 @@ export interface NotificationPayload { read?: boolean; type?: string; referenceId?: string; - referenceType?: 'call' | 'message' | 'status' | 'note' | 'other'; + referenceType?: 'call' | 'message' | 'status' | 'note' | 'chat' | 'other'; metadata?: Record; markAsRead?: () => void; } From 999e6536c35835effb139e52d514f9b20b4173f1 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 25 Sep 2026 18:14:23 -0700 Subject: [PATCH 2/8] RU-T53 Expo 57 update, lots of bug fixes --- .DS_Store | Bin 12292 -> 10244 bytes .eslintrc.js | 19 + AGENTS.md | 2 +- CLAUDE.md | 2 +- app.config.ts | 6 +- package.json | 84 +- ...withResourceBundleDeploymentTarget.test.ts | 28 + plugins/withResourceBundleDeploymentTarget.js | 43 + src/app/(app)/__tests__/index.test.tsx | 25 +- src/app/(app)/_layout.tsx | 4 +- src/app/(app)/index.tsx | 15 +- src/app/(app)/settings.tsx | 21 +- src/app/_layout.tsx | 5 +- src/app/routes/active.tsx | 7 +- src/app/routes/stop/[id].tsx | 7 +- .../call-video-feeds/video-player-modal.tsx | 7 +- .../__tests__/call-images-modal.test.tsx | 2 + src/components/calls/call-detail-menu.tsx | 7 +- src/components/calls/call-images-modal.tsx | 7 +- src/components/calls/call-notes-modal.tsx | 7 +- .../__tests__/checklist-run-sheet.test.tsx | 2 + .../checklists/checklist-run-sheet.tsx | 7 +- .../common/__tests__/native-modal.test.tsx | 52 + src/components/common/native-modal.tsx | 18 + .../__tests__/contact-details-extra.test.tsx | 68 + .../contacts/contact-details-extra.tsx | 2 +- .../maps/__tests__/map-pins.test.tsx | 72 +- .../maps/__tests__/pin-actions.test.tsx | 37 +- src/components/maps/full-screen-map.tsx | 7 +- src/components/maps/map-pins.tsx | 5 +- src/components/maps/pin-detail-modal.tsx | 13 +- .../role-user-selection-modal.test.tsx | 24 +- .../roles-bottom-sheet-save.test.tsx | 123 ++ .../roles/role-user-selection-modal.tsx | 10 +- src/components/roles/roles-bottom-sheet.tsx | 15 +- .../server-url-bottom-sheet-simple.test.tsx | 27 +- .../server-url-bottom-sheet.test.tsx | 410 +++++ .../settings/server-url-bottom-sheet.tsx | 179 ++- .../gps-coordinate-duplication-fix.test.tsx | 3 + .../location-update-validation.test.tsx | 3 + .../status-bottom-sheet-submission.test.tsx | 289 ++++ .../__tests__/status-bottom-sheet.test.tsx | 11 +- .../status-gps-integration-working.test.tsx | 3 + .../__tests__/status-gps-integration.test.tsx | 3 + src/components/status/status-bottom-sheet.tsx | 127 +- .../toast/__tests__/toast-container.test.tsx | 97 +- src/components/toast/toast-container.tsx | 41 +- src/components/ui/alert-dialog/index.tsx | 1 - src/components/ui/bottom-sheet.tsx | 16 +- src/components/ui/menu/index.tsx | 1 - src/components/ui/modal/index.tsx | 1 - .../__tests__/use-map-live-locations.test.ts | 281 ++++ .../__tests__/use-map-signalr-updates.test.ts | 61 + src/hooks/use-map-live-locations.ts | 133 ++ src/hooks/use-map-signalr-updates.ts | 22 +- src/lib/__tests__/live-locations.test.ts | 245 +++ src/lib/__tests__/map-pin-ids.test.ts | 69 + src/lib/live-locations.ts | 224 +++ src/lib/map-pin-ids.ts | 58 + src/lib/storage/__tests__/app.test.ts | 51 + src/lib/storage/app.tsx | 26 +- src/models/v4/contacts/contactResultData.ts | 3 + .../__tests__/app-reset.service.test.ts | 9 +- src/services/__tests__/location-fix.test.ts | 46 +- .../offline-event-manager.service.test.ts | 112 ++ .../__tests__/signalr.service.test.ts | 90 ++ src/services/app-reset.service.ts | 11 +- src/services/location-fix.ts | 84 +- src/services/offline-event-manager.service.ts | 135 +- src/services/signalr.service.ts | 36 +- src/stores/app/__tests__/core-store.test.ts | 104 +- src/stores/app/core-store.ts | 63 +- src/stores/roles/__tests__/store.test.ts | 44 + src/stores/roles/store.ts | 7 +- .../signalr/__tests__/signalr-store.test.ts | 255 ++- src/stores/signalr/signalr-store.ts | 163 +- src/stores/status/__tests__/store.test.ts | 100 ++ src/stores/status/store.ts | 137 +- src/stores/toast/store.ts | 17 + src/translations/ar.json | 326 ++-- src/translations/de.json | 326 ++-- src/translations/el.json | 326 ++-- src/translations/en.json | 326 ++-- src/translations/es.json | 326 ++-- src/translations/fr.json | 326 ++-- src/translations/it.json | 326 ++-- src/translations/pl.json | 326 ++-- src/translations/sv.json | 326 ++-- src/translations/uk.json | 326 ++-- yarn.lock | 1393 +++++++++-------- 90 files changed, 6620 insertions(+), 2584 deletions(-) create mode 100644 plugins/__tests__/withResourceBundleDeploymentTarget.test.ts create mode 100644 plugins/withResourceBundleDeploymentTarget.js create mode 100644 src/components/common/__tests__/native-modal.test.tsx create mode 100644 src/components/common/native-modal.tsx create mode 100644 src/components/contacts/__tests__/contact-details-extra.test.tsx create mode 100644 src/components/roles/__tests__/roles-bottom-sheet-save.test.tsx create mode 100644 src/components/settings/__tests__/server-url-bottom-sheet.test.tsx create mode 100644 src/components/status/__tests__/status-bottom-sheet-submission.test.tsx create mode 100644 src/hooks/__tests__/use-map-live-locations.test.ts create mode 100644 src/hooks/use-map-live-locations.ts create mode 100644 src/lib/__tests__/live-locations.test.ts create mode 100644 src/lib/__tests__/map-pin-ids.test.ts create mode 100644 src/lib/live-locations.ts create mode 100644 src/lib/map-pin-ids.ts create mode 100644 src/lib/storage/__tests__/app.test.ts create mode 100644 src/stores/roles/__tests__/store.test.ts diff --git a/.DS_Store b/.DS_Store index 56be171ba8515ffc38c16fccfae410b6942b839d..5ea87c4b3780aedf9a578d42b37900d75819f9ae 100644 GIT binary patch delta 438 zcmb7AzfZzI6#lM&G?8E-6xz5{qQs#zG8p4Rz{G*X7-MvX7JngyDn*T(112Uq2sfFS zIJi4_vxA$)|H6O5$>?i!ar9l@y?5{4_rAMt&={;~0NB#eRtYdDmC1vsB!-9Cp{4UV zGpf@W6>>&(pouno)KRaw!q)ENA}&W|R^eqMEAqxF6R-Ifi9gTWit|F14X-yuLeZF- z#4cQTa9~rkaDr_({8P!rBp_1edTNxE0+y$*&{p2XX8Ka%nmSrc|?TKHr delta 732 zcmbtSJx>%-6g_WdV241$0L!wttQln#62Y);K}Zydi7_O`h|pOG`vGn;%yW17sEiaO z6w;8}VBtrF9m(X?6qdxq_yg?x0saI%v%zFtN?&sC+`0G6x#zw+`%m{*eE{mgcBKGJ zGIVLl)9a%<0Q}w~e%j6I-=GPclwMs!pkmzlunN&t=wXIA9fm><{elu5ac zt0qh>lT~sya_~mQq`CRKbqEHscti$`4TNM|rZupG8o8^OX156Itlq{e)@x{DYcbU2 zQm9M(mLlx;4>tcdKhM z?`6z&?}@sXA~+&-a)C8e{uPWb6m+L;tPZrSczf>L?Cg|ZEEeU5x3JU!XdkSj^mo8y z=8n?Naw7_Lw84rpn*(318!i{2xsYTnYg|(GvQF!ROs7BhIGm-mAXyq1QfKBo{a8u< r>irzhMLwR%Ub=_vKUQ4-WlMihP>{QQIfsf|c^SNuPeaH5-+BHI*-EZV diff --git a/.eslintrc.js b/.eslintrc.js index a69187ca..9aed243c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -27,6 +27,18 @@ module.exports = { 'import/no-cycle': 'off', // Disabled due to performance issues 'simple-import-sort/imports': 'error', // Import configuration for `eslint-plugin-simple-import-sort` 'simple-import-sort/exports': 'error', // Export configuration for `eslint-plugin-simple-import-sort` + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'react-native', + importNames: ['Modal'], + message: "Use NativeModal from '@/components/common/native-modal'. A native Modal is its own window, so toasts drawn in the app window are hidden under it; NativeModal carries a toast host inside.", + }, + ], + }, + ], '@typescript-eslint/no-unused-vars': 'off', 'unused-imports/no-unused-imports': 'off', 'unused-imports/no-unused-vars': [ @@ -39,6 +51,13 @@ module.exports = { ], }, overrides: [ + { + // The one place React Native's Modal may be used directly. + files: ['src/components/common/native-modal.tsx'], + rules: { + 'no-restricted-imports': 'off', + }, + }, // Configuration for translations files (i18next) { files: ['src/translations/*.json'], diff --git a/AGENTS.md b/AGENTS.md index 957dee67..7d8dd25a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ | Layer | Technology | |---|---| -| Framework | React Native 0.81 + Expo SDK 54 (managed, prebuild) | +| Framework | React Native 0.86 + Expo SDK 57 (managed, prebuild) | | Language | TypeScript (strict mode) | | Routing | Expo Router (file-based, typed routes) | | State | Zustand (with MMKV persistence) | diff --git a/CLAUDE.md b/CLAUDE.md index f1d268fe..3e57ef19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ Some tasks require scanning **every file** -- e.g. "find all dead exports", "lis | Layer | Technology | |---|---| -| Framework | React Native 0.81 + Expo SDK 54 (managed, prebuild) | +| Framework | React Native 0.86 + Expo SDK 57 (managed, prebuild) | | Language | TypeScript (strict mode) | | Routing | Expo Router (file-based, typed routes) | | State | Zustand (with MMKV persistence) | diff --git a/app.config.ts b/app.config.ts index a2ce6e37..cc5c879c 100644 --- a/app.config.ts +++ b/app.config.ts @@ -221,6 +221,9 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ }, ios: { deploymentTarget: '18.1', + // Apps built with the iOS 27 SDK must adopt the UIKit scene life cycle or they are + // killed at launch. Remove once on SDK 58, whose template adopts it by default. + enableSceneSupport: true, }, }, ], @@ -264,7 +267,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ 'expo-navigation-bar', { position: 'relative', - visibility: 'hidden', + hidden: true, behavior: 'inset-touch', }, ], @@ -298,6 +301,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ }, ], './plugins/withInCallAudioModule.js', + './plugins/withResourceBundleDeploymentTarget.js', ['./plugins/with-app-icon-badge.js', appIconBadgeConfig], ], extra: { diff --git a/package.json b/package.json index b5ea1a1c..b96b9f66 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@config-plugins/react-native-webrtc": "15.0.1", "@dev-plugins/react-query": "0.4.0", "@expo/html-elements": "0.10.1", - "@expo/metro-runtime": "~56.0.20", + "@expo/metro-runtime": "~57.0.16", "@gluestack-ui/core": "^5.0.15", "@gluestack-ui/utils": "^5.0.6", "@gorhom/bottom-sheet": "5.2.13", @@ -83,39 +83,39 @@ "buffer": "6.0.3", "countly-sdk-react-native-bridge": "26.1.0", "date-fns": "4.1.0", - "expo": "~56.0.20", - "expo-application": "~56.0.3", - "expo-asset": "~56.0.23", - "expo-audio": "~56.0.13", - "expo-auth-session": "~56.0.17", - "expo-build-properties": "~56.0.26", - "expo-clipboard": "~56.0.4", - "expo-constants": "~56.0.24", - "expo-crypto": "~56.0.4", - "expo-dev-client": "~56.0.25", - "expo-device": "~56.0.4", - "expo-document-picker": "~56.0.4", - "expo-file-system": "~56.0.10", - "expo-font": "~56.0.7", - "expo-image": "~56.0.12", - "expo-image-manipulator": "~56.0.25", - "expo-image-picker": "~56.0.24", - "expo-keep-awake": "~56.0.3", - "expo-linking": "~56.0.17", - "expo-localization": "~56.0.6", - "expo-location": "~56.0.24", - "expo-navigation-bar": "~56.0.3", - "expo-notifications": "~56.0.24", - "expo-router": "~56.2.19", - "expo-screen-orientation": "~56.0.5", - "expo-secure-store": "~56.0.4", - "expo-sharing": "~56.0.25", - "expo-splash-screen": "~56.0.14", - "expo-status-bar": "~56.0.4", - "expo-system-ui": "~56.0.5", - "expo-task-manager": "~56.0.26", - "expo-video": "~56.1.4", - "expo-web-browser": "~56.0.6", + "expo": "~57.0.25", + "expo-application": "~57.0.3", + "expo-asset": "~57.0.18", + "expo-audio": "~57.0.5", + "expo-auth-session": "~57.0.13", + "expo-build-properties": "~57.0.22", + "expo-clipboard": "~57.0.2", + "expo-constants": "~57.0.19", + "expo-crypto": "~57.0.3", + "expo-dev-client": "~57.0.19", + "expo-device": "~57.0.2", + "expo-document-picker": "~57.0.2", + "expo-file-system": "~57.0.7", + "expo-font": "~57.0.4", + "expo-image": "~57.0.5", + "expo-image-manipulator": "~57.0.20", + "expo-image-picker": "~57.0.20", + "expo-keep-awake": "~57.0.2", + "expo-linking": "~57.0.11", + "expo-localization": "~57.0.2", + "expo-location": "~57.0.20", + "expo-navigation-bar": "~57.0.2", + "expo-notifications": "~57.0.21", + "expo-router": "~57.0.23", + "expo-screen-orientation": "~57.0.2", + "expo-secure-store": "~57.0.4", + "expo-sharing": "~57.0.22", + "expo-splash-screen": "~57.0.9", + "expo-status-bar": "~57.0.1", + "expo-system-ui": "~57.0.4", + "expo-task-manager": "~57.0.20", + "expo-video": "~57.0.5", + "expo-web-browser": "~57.0.3", "geojson": "0.5.0", "i18next": "23.14.0", "livekit-client": "^2.20.1", @@ -131,25 +131,25 @@ "react-error-boundary": "4.0.13", "react-hook-form": "7.53.2", "react-i18next": "15.0.3", - "react-native": "0.85.3", + "react-native": "0.86.3", "react-native-base64": "0.2.2", "react-native-ble-manager": "12.2.1", "react-native-callkeep": "github:Irfanwani/react-native-callkeep#957193d0716f1c2dfdc18e627cbff0f8a0800971", "react-native-css": "^3.0.7", "react-native-edge-to-edge": "1.6.0", "react-native-flash-message": "0.4.2", - "react-native-gesture-handler": "~2.31.1", - "react-native-keyboard-controller": "1.21.6", + "react-native-gesture-handler": "~2.32.0", + "react-native-keyboard-controller": "1.21.9", "react-native-logs": "5.3.0", "react-native-mmkv": "3.1.0", - "react-native-reanimated": "4.3.1", + "react-native-reanimated": "4.5.1", "react-native-restart": "0.0.27", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", "react-native-svg": "15.15.4", "react-native-web": "0.21.2", "react-native-webview": "13.16.1", - "react-native-worklets": "0.8.3", + "react-native-worklets": "0.10.1", "react-query-kit": "3.3.2", "sanitize-html": "2.17.0", "tailwind-variants": "0.2.1", @@ -160,8 +160,8 @@ "@babel/core": "^7.29.0", "@commitlint/cli": "19.2.2", "@commitlint/config-conventional": "19.2.2", - "@expo/config": "^56.0.0", - "@react-native/jest-preset": "~0.85.3", + "@expo/config": "^57.0.0", + "@react-native/jest-preset": "~0.86.3", "@tailwindcss/postcss": "^4.2.0", "@testing-library/jest-dom": "6.5.0", "@testing-library/react-native": "12.9.0", @@ -197,7 +197,7 @@ "eslint-plugin-unused-imports": "2.0.0", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", - "jest-expo": "~56.0.5", + "jest-expo": "~57.0.5", "jest-junit": "16.0.0", "lint-staged": "15.2.11", "np": "10.0.7", diff --git a/plugins/__tests__/withResourceBundleDeploymentTarget.test.ts b/plugins/__tests__/withResourceBundleDeploymentTarget.test.ts new file mode 100644 index 00000000..0ebd45f9 --- /dev/null +++ b/plugins/__tests__/withResourceBundleDeploymentTarget.test.ts @@ -0,0 +1,28 @@ +import { addResourceBundleDeploymentTarget } from '../withResourceBundleDeploymentTarget'; + +const podfile = `target 'ResgridUnit' do + post_install do |installer| + react_native_post_install(installer) + end +end +`; + +describe('addResourceBundleDeploymentTarget', () => { + it('raises resource bundles inside the post_install hook', () => { + const lines = addResourceBundleDeploymentTarget(podfile).split('\n'); + const hookLine = lines.findIndex((line) => line.includes('post_install do |installer|')); + + expect(lines[hookLine + 1]).toContain('@generated begin resgrid-resource-bundle-deployment-target'); + expect(lines.join('\n')).toContain('result.resource_bundle_targets.each'); + expect(lines.join('\n')).toContain("build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = min_ios_version_supported"); + expect(lines.findIndex((line) => line.includes('react_native_post_install'))).toBeGreaterThan(hookLine); + }); + + it('only adds the block once across repeated prebuilds', () => { + const once = addResourceBundleDeploymentTarget(podfile); + const twice = addResourceBundleDeploymentTarget(once); + + expect(twice).toBe(once); + expect(twice.match(/resource_bundle_targets/g)).toHaveLength(1); + }); +}); diff --git a/plugins/withResourceBundleDeploymentTarget.js b/plugins/withResourceBundleDeploymentTarget.js new file mode 100644 index 00000000..98f4f967 --- /dev/null +++ b/plugins/withResourceBundleDeploymentTarget.js @@ -0,0 +1,43 @@ +/* eslint-env node */ +const { withPodfile } = require('@expo/config-plugins'); +const { mergeContents } = require('@expo/config-plugins/build/utils/generateCode'); + +/** + * Raises the iOS deployment target of CocoaPods resource-bundle targets to React Native's floor. + * + * `react_native_post_install` lifts every pod's native target to `min_ios_version_supported`, but + * skips the resource bundles that pods such as MapboxMaps, RNSVG and react-native-permissions + * declare. Those keep their podspec minimum (12.4 / 14.0), which Xcode 27 rejects outright — its + * supported range starts at iOS 15.0 — failing the build with "IPHONEOS_DEPLOYMENT_TARGET is set to 12.4". + */ + +const POST_INSTALL_BLOCK = ` installer.target_installation_results.pod_target_installation_results.each_value do |result| + result.resource_bundle_targets.each do |bundle_target| + bundle_target.build_configurations.each do |build_config| + deployment_target = build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + next if deployment_target.nil? || deployment_target.to_f >= min_ios_version_supported.to_f + + build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = min_ios_version_supported + end + end + end`; + +function addResourceBundleDeploymentTarget(podfile) { + return mergeContents({ + tag: 'resgrid-resource-bundle-deployment-target', + src: podfile, + newSrc: POST_INSTALL_BLOCK, + anchor: /^\s*post_install do \|installer\|/, + offset: 1, + comment: '#', + }).contents; +} + +const withResourceBundleDeploymentTarget = (config) => + withPodfile(config, (podfileConfig) => { + podfileConfig.modResults.contents = addResourceBundleDeploymentTarget(podfileConfig.modResults.contents); + return podfileConfig; + }); + +module.exports = withResourceBundleDeploymentTarget; +module.exports.addResourceBundleDeploymentTarget = addResourceBundleDeploymentTarget; diff --git a/src/app/(app)/__tests__/index.test.tsx b/src/app/(app)/__tests__/index.test.tsx index e53a9e87..134ecc68 100644 --- a/src/app/(app)/__tests__/index.test.tsx +++ b/src/app/(app)/__tests__/index.test.tsx @@ -29,7 +29,11 @@ jest.mock('@/hooks/use-app-lifecycle'); jest.mock('@/stores/app/location-store'); jest.mock('@/services/location'); jest.mock('@/hooks/use-map-signalr-updates', () => ({ - useMapSignalRUpdates: jest.fn(), + useMapSignalRUpdates: jest.fn(() => ({ requestRefresh: jest.fn() })), +})); +jest.mock('@/hooks/use-map-live-locations', () => ({ + useMapLiveLocations: jest.fn(), + applyLiveLocationsSince: jest.fn((pins: unknown) => pins), })); jest.mock('@/api/mapping/mapping', () => ({ getMapDataAndMarkers: jest.fn().mockResolvedValue({ @@ -246,6 +250,25 @@ describe('Map Component - App Lifecycle', () => { unmount(); }); + it('should move pins with realtime positions and re-apply them over the initial fetch', async () => { + const { useMapSignalRUpdates } = jest.requireMock('@/hooks/use-map-signalr-updates'); + const { useMapLiveLocations, applyLiveLocationsSince } = jest.requireMock('@/hooks/use-map-live-locations'); + const requestRefresh = jest.fn(); + (useMapSignalRUpdates as jest.Mock).mockReturnValue({ requestRefresh }); + + const { unmount } = render(, { wrapper: TestWrapper }); + + // The live-location hook gets the map's pins, its setter and the REST refetch it may request. + expect(useMapLiveLocations).toHaveBeenCalledWith(expect.any(Array), expect.any(Function), requestRefresh); + + // The initial REST snapshot is passed through the positions pushed while it was in flight. + await waitFor(() => { + expect(applyLiveLocationsSince).toHaveBeenCalledWith([], expect.any(Number)); + }); + + unmount(); + }); + it('should handle app lifecycle changes', async () => { // Test with inactive app mockUseAppLifecycle.mockReturnValue({ diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index ce442756..a7c2cb19 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -32,7 +32,7 @@ import { type GetConfigResultData } from '@/models/v4/configs/getConfigResultDat import { audioService } from '@/services/audio.service'; import { bluetoothAudioService } from '@/services/bluetooth-audio.service'; import { usePushNotifications } from '@/services/push-notification'; -import { useCoreStore } from '@/stores/app/core-store'; +import { UnitListUnavailableError, useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; import { dataProtectionStore } from '@/stores/data-protection/store'; import { FeatureFlagKeys, featureFlagsStore } from '@/stores/feature-flags/store'; @@ -237,7 +237,7 @@ export default function TabLayout() { // Transient connectivity failures are expected (e.g. brief network loss) and // already logged deeper in the stack, so keep them at warn to avoid reporting // the same recoverable error to Sentry. Genuine failures still report as errors. - if (isNetworkError(error)) { + if (isNetworkError(error) || error instanceof UnitListUnavailableError) { logger.warn({ message: 'Failed to initialize app due to network connectivity', context: { error }, diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index e9c0332e..057cff53 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -17,12 +17,14 @@ import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { WeatherAlertBanner } from '@/components/weather-alerts/weather-alert-banner'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAppLifecycle } from '@/hooks/use-app-lifecycle'; +import { applyLiveLocationsSince, useMapLiveLocations } from '@/hooks/use-map-live-locations'; import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates'; import { useWeatherAlertBanner } from '@/hooks/use-weather-alert-banner'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; import { applyPitchHysteresis, applyZoomHysteresis, createCirclePolygon, normalizeHeading, normalizeSpeed, smoothSpeed, zoomForSpeed } from '@/lib/map-camera'; import { getDepartmentMapCenter } from '@/lib/map-center'; +import { getPinEntityId } from '@/lib/map-pin-ids'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { locationService } from '@/services/location'; import { useCoreStore } from '@/stores/app/core-store'; @@ -121,7 +123,9 @@ function MapContent() { const [styleURL, setStyleURL] = useState({ styleURL: getMapStyle() }); - useMapSignalRUpdates(setMapPins); + const { requestRefresh: requestMapRefresh } = useMapSignalRUpdates(setMapPins); + // Realtime unit/personnel positions move the pins in place between REST refetches. + useMapLiveLocations(mapPins, setMapPins, requestMapRefresh); // Throttle state for programmatic camera follow (see effect below) const lastCameraFollowRef = useRef(0); @@ -437,10 +441,11 @@ function MapContent() { const fetchMapDataAndMarkers = async () => { try { + const fetchStartedAt = Date.now(); const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal); if (mapDataAndMarkers && mapDataAndMarkers.Data) { - setMapPins(mapDataAndMarkers.Data.MapMakerInfos); + setMapPins(applyLiveLocationsSince(mapDataAndMarkers.Data.MapMakerInfos, fetchStartedAt)); } } catch (error) { // Don't log aborted requests as errors @@ -503,19 +508,19 @@ function MapContent() { logger.info({ message: 'Setting call as current call', context: { - callId: pin.Id, + callId: getPinEntityId(pin), callTitle: pin.Title, }, }); - await useCoreStore.getState().setActiveCall(pin.Id); + await useCoreStore.getState().setActiveCall(getPinEntityId(pin)); useToastStore.getState().showToast('success', t('map.call_set_as_current')); } catch (error) { logger.error({ message: 'Failed to set call as current call', context: { error, - callId: pin.Id, + callId: getPinEntityId(pin), callTitle: pin.Title, }, }); diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index 40e28421..32ce1056 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -74,6 +74,25 @@ export default function Settings() { } }, [signOut, trackEvent, activeUnit]); + // Tokens and cached data belong to the old server — sign out so the user + // logs in against the newly selected one. + const handleServerUrlChanged = useCallback(async () => { + if (!isAuthenticated) { + return; + } + + trackEvent('settings_server_url_changed_logout'); + + try { + await signOut(); + } catch (error) { + logger.error({ + message: 'Error during logout after server URL change', + context: { error }, + }); + } + }, [isAuthenticated, signOut, trackEvent]); + const handleLoginInfoSubmit = async (data: { username: string; password: string }) => { logger.info({ message: 'Updating login info', @@ -152,7 +171,7 @@ export default function Settings() { setShowLoginInfo(false)} onSubmit={handleLoginInfoSubmit} /> - setShowServerUrl(false)} /> + setShowServerUrl(false)} onUrlChanged={handleServerUrlChanged} /> setShowUnitSelection(false)} /> {/* Logout Confirmation Dialog */} diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index f95e782d..20020534 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -229,10 +229,13 @@ function Providers({ children }: { children: React.ReactNode }) { - + {/* Outside GluestackUIProvider on purpose: its portal host renders every overlay (the status + sheet, drawers, modals) after its children, so a toast in there drew underneath them. As a + later sibling this layer draws on top. Native Modal windows carry their own host (NativeModal). */} + ); diff --git a/src/app/routes/active.tsx b/src/app/routes/active.tsx index 160937cc..234460c5 100644 --- a/src/app/routes/active.tsx +++ b/src/app/routes/active.tsx @@ -2,8 +2,9 @@ import { useLocalSearchParams, useRouter } from 'expo-router'; import { Compass, LogOut, Navigation, SkipForward } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Alert, AppState, Modal, ScrollView, StyleSheet, TextInput, TouchableOpacity } from 'react-native'; +import { Alert, AppState, ScrollView, StyleSheet, TextInput, TouchableOpacity } from 'react-native'; +import { NativeModal } from '@/components/common/native-modal'; import Mapbox from '@/components/maps/mapbox'; import { RouteDeviationBanner } from '@/components/routes/route-deviation-banner'; import { StopCard } from '@/components/routes/stop-card'; @@ -380,7 +381,7 @@ export default function ActiveRouteScreen() {
{/* Skip reason modal */} - setSkipModalVisible(false)}> + setSkipModalVisible(false)}> @@ -398,7 +399,7 @@ export default function ActiveRouteScreen() { - +
); } diff --git a/src/app/routes/stop/[id].tsx b/src/app/routes/stop/[id].tsx index 351de44a..22b50d7d 100644 --- a/src/app/routes/stop/[id].tsx +++ b/src/app/routes/stop/[id].tsx @@ -3,9 +3,10 @@ import { CheckCircleIcon, ClockIcon, LogInIcon, LogOutIcon, MapPinIcon, SkipForw import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Modal, ScrollView, StyleSheet, TextInput, TouchableOpacity, View } from 'react-native'; +import { ScrollView, StyleSheet, TextInput, TouchableOpacity, View } from 'react-native'; import { Loading } from '@/components/common/loading'; +import { NativeModal } from '@/components/common/native-modal'; import { Camera, FillLayer, LineLayer, MapView, PointAnnotation, ShapeSource, StyleURL } from '@/components/maps/mapbox'; import { Badge, BadgeText } from '@/components/ui/badge'; import { Box } from '@/components/ui/box'; @@ -324,7 +325,7 @@ export default function StopDetailScreen() { {/* Skip reason modal */} - setSkipModalVisible(false)}> + setSkipModalVisible(false)}> @@ -342,7 +343,7 @@ export default function StopDetailScreen() { - + ); } diff --git a/src/components/call-video-feeds/video-player-modal.tsx b/src/components/call-video-feeds/video-player-modal.tsx index 0d938c0e..120d9a96 100644 --- a/src/components/call-video-feeds/video-player-modal.tsx +++ b/src/components/call-video-feeds/video-player-modal.tsx @@ -2,9 +2,10 @@ import { useVideoPlayer, VideoView } from 'expo-video'; import { CopyIcon, XIcon } from 'lucide-react-native'; import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { Modal, StyleSheet } from 'react-native'; +import { StyleSheet } from 'react-native'; import { WebView } from 'react-native-webview'; +import { NativeModal } from '@/components/common/native-modal'; import { Box } from '@/components/ui/box'; import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; import { Heading } from '@/components/ui/heading'; @@ -100,7 +101,7 @@ export const VideoPlayerModal: React.FC = ({ isOpen, onCl }; return ( - + {/* Header */} @@ -115,7 +116,7 @@ export const VideoPlayerModal: React.FC = ({ isOpen, onCl {/* Player */} {renderPlayer()} - + ); }; diff --git a/src/components/calls/__tests__/call-images-modal.test.tsx b/src/components/calls/__tests__/call-images-modal.test.tsx index 343fda1a..0145c9b9 100644 --- a/src/components/calls/__tests__/call-images-modal.test.tsx +++ b/src/components/calls/__tests__/call-images-modal.test.tsx @@ -26,6 +26,8 @@ jest.mock('@/stores/toast/store', () => ({ useToastStore: (selector: any) => (selector ? selector(mockToastState) : mockToastState), })); +// NativeModal carries a toast host; toast layering is covered by the toast-container suite. +jest.mock('@/components/toast/toast-container', () => ({ ModalToastHost: () => null })); jest.mock('@/lib/logging', () => ({ logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, })); diff --git a/src/components/calls/call-detail-menu.tsx b/src/components/calls/call-detail-menu.tsx index 566edbf8..8c0bb4e4 100644 --- a/src/components/calls/call-detail-menu.tsx +++ b/src/components/calls/call-detail-menu.tsx @@ -1,8 +1,9 @@ import { EditIcon, MoreVerticalIcon, XIcon } from 'lucide-react-native'; import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Modal, Pressable as RNPressable, StyleSheet, View } from 'react-native'; +import { Pressable as RNPressable, StyleSheet, View } from 'react-native'; +import { NativeModal } from '@/components/common/native-modal'; import { HStack } from '@/components/ui/hstack'; import { Pressable } from '@/components/ui/pressable'; import { Text } from '@/components/ui/text'; @@ -72,7 +73,7 @@ export const useCallDetailMenu = ({ onEditCall, onCloseCall, canUserCreateCalls } return ( - + @@ -106,7 +107,7 @@ export const useCallDetailMenu = ({ onEditCall, onCloseCall, canUserCreateCalls - + ); }, [isKebabMenuOpen, canUserCreateCalls, t, onEditCall, onCloseCall]); diff --git a/src/components/calls/call-images-modal.tsx b/src/components/calls/call-images-modal.tsx index 8a13fe40..554998b8 100644 --- a/src/components/calls/call-images-modal.tsx +++ b/src/components/calls/call-images-modal.tsx @@ -6,11 +6,12 @@ import { CameraIcon, ChevronLeftIcon, ChevronRightIcon, ImageIcon, PlusIcon, X } import { useColorScheme } from 'nativewind'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Keyboard, Modal, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { Keyboard, StyleSheet, TouchableOpacity, View } from 'react-native'; import { KeyboardStickyView } from 'react-native-keyboard-controller'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Loading } from '@/components/common/loading'; +import { NativeModal } from '@/components/common/native-modal'; import ZeroState from '@/components/common/zero-state'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAuthStore } from '@/lib'; @@ -446,7 +447,7 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call return ( <> - + {/* Header */} @@ -467,7 +468,7 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call {/* Content */} {renderContent()} - + {/* Full Screen Image Modal */} setFullScreenImage(null)} imageSource={fullScreenImage?.source || { uri: '' }} imageName={fullScreenImage?.name} /> diff --git a/src/components/calls/call-notes-modal.tsx b/src/components/calls/call-notes-modal.tsx index d7dbcf73..1eb3e98e 100644 --- a/src/components/calls/call-notes-modal.tsx +++ b/src/components/calls/call-notes-modal.tsx @@ -1,9 +1,10 @@ import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { FlatList, Keyboard, Modal, SafeAreaView, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { FlatList, Keyboard, SafeAreaView, StyleSheet, TouchableOpacity, View } from 'react-native'; import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; +import { NativeModal } from '@/components/common/native-modal'; import { ProtectedText } from '@/components/data-protection/protected-text'; import { SearchIcon, X } from '@/components/ui/lucide-icons'; import { useAnalytics } from '@/hooks/use-analytics'; @@ -109,7 +110,7 @@ const CallNotesModal = ({ isOpen, onClose, callId }: CallNotesModalProps) => { } return ( - + {/* Header */} @@ -161,7 +162,7 @@ const CallNotesModal = ({ isOpen, onClose, callId }: CallNotesModalProps) => { - + ); }; diff --git a/src/components/checklists/__tests__/checklist-run-sheet.test.tsx b/src/components/checklists/__tests__/checklist-run-sheet.test.tsx index 6d68a78d..f60a8668 100644 --- a/src/components/checklists/__tests__/checklist-run-sheet.test.tsx +++ b/src/components/checklists/__tests__/checklist-run-sheet.test.tsx @@ -10,6 +10,8 @@ jest.mock('@/components/checklists/signature-pad', () => ({ ChecklistSignaturePa jest.mock('@/components/ui/button', () => { const { Pressable, Text } = require('react-native'); return { Button: ({ isDisabled, ...props }: { isDisabled?: boolean }) => , ButtonText: Text }; }); jest.mock('@/components/ui/text', () => ({ Text: require('react-native').Text })); jest.mock('react-native-safe-area-context', () => ({ SafeAreaView: require('react-native').View })); +// NativeModal carries a toast host; toast layering is covered by the toast-container suite. +jest.mock('@/components/toast/toast-container', () => ({ ModalToastHost: () => null })); jest.mock('@/stores/auth/store', () => { const { create } = jest.requireActual('zustand'); return { __esModule: true, default: create(() => ({ userId: 'author' })) }; }); jest.mock('@/stores/checklists/store', () => { const { create } = jest.requireActual('zustand'); return { useChecklistsStore: create(() => ({ active: null, update: jest.fn().mockResolvedValue(undefined), queue: jest.fn().mockResolvedValue(undefined), close: jest.fn(), addImage: jest.fn().mockResolvedValue(undefined) })) }; }); import React from 'react'; diff --git a/src/components/checklists/checklist-run-sheet.tsx b/src/components/checklists/checklist-run-sheet.tsx index d2785334..60555c1f 100644 --- a/src/components/checklists/checklist-run-sheet.tsx +++ b/src/components/checklists/checklist-run-sheet.tsx @@ -5,11 +5,12 @@ import * as ImagePicker from 'expo-image-picker'; import * as Location from 'expo-location'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Alert, AppState, Image, Modal, ScrollView, TextInput, View } from 'react-native'; +import { Alert, AppState, Image, ScrollView, TextInput, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { attestChecklistRun, getChecklistImage } from '@/api/checklists/checklists'; import { ChecklistSignaturePad } from '@/components/checklists/signature-pad'; +import { NativeModal } from '@/components/common/native-modal'; import { Button, ButtonText } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { checklistFailed, isChecklistRequired, visibleChecklistItems } from '@/lib/checklists/form'; @@ -106,7 +107,7 @@ export const ChecklistRunSheet: React.FC = () => { : []; const evidence = item ? draft.images.filter((i) => i.itemId === item.Id).length + draft.run.Files.filter((i) => i.ItemId === item.Id).length : 0; return ( - useChecklistsStore.getState().close()}> + useChecklistsStore.getState().close()}> {draft.run.Form.Name} @@ -338,6 +339,6 @@ export const ChecklistRunSheet: React.FC = () => { - + ); }; diff --git a/src/components/common/__tests__/native-modal.test.tsx b/src/components/common/__tests__/native-modal.test.tsx new file mode 100644 index 00000000..b6569b14 --- /dev/null +++ b/src/components/common/__tests__/native-modal.test.tsx @@ -0,0 +1,52 @@ +import { act, render, screen } from '@testing-library/react-native'; +import React from 'react'; +import { Text } from 'react-native'; + +import { useToastStore } from '@/stores/toast/store'; + +import { NativeModal } from '../native-modal'; + +jest.mock('@/components/toast/toast', () => ({ + ToastMessage: ({ message }: { message: string }) => { + const { Text: MockText } = require('react-native'); + return {message}; + }, +})); + +describe('NativeModal', () => { + beforeEach(() => { + useToastStore.setState({ toasts: [], modalHosts: [] }); + }); + + it('renders its content and shows toasts inside the modal window', () => { + render( + + Sheet content + + ); + + act(() => useToastStore.setState({ toasts: [{ id: '1', type: 'error', message: 'Check-in failed' }] })); + + expect(screen.getByText('Sheet content')).toBeTruthy(); + expect(screen.getByTestId('modal-toast-host')).toBeTruthy(); + expect(screen.getByText('Check-in failed')).toBeTruthy(); + expect(useToastStore.getState().modalHosts).toHaveLength(1); + }); + + it('releases its toast host when it is hidden', () => { + const { rerender } = render( + + Sheet content + + ); + expect(useToastStore.getState().modalHosts).toHaveLength(1); + + rerender( + + Sheet content + + ); + + expect(useToastStore.getState().modalHosts).toEqual([]); + }); +}); diff --git a/src/components/common/native-modal.tsx b/src/components/common/native-modal.tsx new file mode 100644 index 00000000..befae77c --- /dev/null +++ b/src/components/common/native-modal.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Modal, type ModalProps } from 'react-native'; + +import { ModalToastHost } from '@/components/toast/toast-container'; + +/** + * React Native's Modal with a toast host inside it. Use it instead of importing Modal from + * 'react-native' directly. + * + * A native Modal is its own window above the app, so toasts drawn in the app window — every error + * or confirmation shown while the modal is open — sit underneath it where nobody sees them. + */ +export const NativeModal: React.FC = ({ children, ...props }) => ( + + {children} + + +); diff --git a/src/components/contacts/__tests__/contact-details-extra.test.tsx b/src/components/contacts/__tests__/contact-details-extra.test.tsx new file mode 100644 index 00000000..c52d2990 --- /dev/null +++ b/src/components/contacts/__tests__/contact-details-extra.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { type ContactResultData, ContactType } from '@/models/v4/contacts/contactResultData'; + +import { ContactDetailsExtra } from '../contact-details-extra'; + +jest.mock('@/stores/contacts/store', () => ({ + useContactsStore: { + getState: () => ({ + fetchContactNotes: jest.fn().mockResolvedValue(undefined), + fetchContactDetails: jest.fn().mockResolvedValue(undefined), + }), + }, +})); + +jest.mock('@/components/data-protection/protected-reveal-bar', () => ({ + ProtectedRevealBar: () => null, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const makeContact = (customFields: ContactResultData['CustomFields']): ContactResultData => + ({ + ContactId: 'contact-1', + ContactType: ContactType.Person, + Name: 'Jane Doe', + CustomFields: customFields, + }) as ContactResultData; + +describe('ContactDetailsExtra custom fields', () => { + it('shows the display value the server resolved for option fields', () => { + const contact = makeContact([ + { UdfFieldId: 'outcome', Label: 'Outcome', Value: 'tx', DisplayValue: 'Transported', FieldDataType: 11, GroupName: null, SortOrder: 0 }, + { UdfFieldId: 'priority', Label: 'Priority', Value: 'p1', DisplayValue: 'Priority 1', FieldDataType: 6, GroupName: 'Triage', SortOrder: 1 }, + ]); + + const { unmount } = render(); + + expect(screen.getByText('Transported')).toBeTruthy(); + expect(screen.getByText('Priority 1')).toBeTruthy(); + expect(screen.getByText('Triage · Priority')).toBeTruthy(); + expect(screen.queryByText('tx')).toBeNull(); + unmount(); + }); + + it('falls back to the stored value when the server sends no display value', () => { + const contact = makeContact([{ UdfFieldId: 'notes', Label: 'Notes', Value: 'Referred to crisis line', FieldDataType: 11, SortOrder: 0 }]); + + const { unmount } = render(); + + expect(screen.getByText('Referred to crisis line')).toBeTruthy(); + unmount(); + }); + + it('hides custom fields when the host renders its own', () => { + const contact = makeContact([{ UdfFieldId: 'outcome', Label: 'Outcome', Value: 'tx', DisplayValue: 'Transported', FieldDataType: 11, SortOrder: 0 }]); + + const { unmount } = render(); + + expect(screen.queryByTestId('contact-custom-fields')).toBeNull(); + unmount(); + }); +}); diff --git a/src/components/contacts/contact-details-extra.tsx b/src/components/contacts/contact-details-extra.tsx index 1b897d50..af6f1db0 100644 --- a/src/components/contacts/contact-details-extra.tsx +++ b/src/components/contacts/contact-details-extra.tsx @@ -110,7 +110,7 @@ export const ContactDetailsExtra: React.FC = ({ contac {customFields.map((field) => ( {field.GroupName ? `${field.GroupName} · ${field.Label}` : field.Label} - {field.Value} + {field.DisplayValue || field.Value} ))} diff --git a/src/components/maps/__tests__/map-pins.test.tsx b/src/components/maps/__tests__/map-pins.test.tsx index 83201049..c91da1af 100644 --- a/src/components/maps/__tests__/map-pins.test.tsx +++ b/src/components/maps/__tests__/map-pins.test.tsx @@ -51,21 +51,22 @@ describe('MapPins', () => { Object.keys(mockMarkerMounts).forEach((key) => delete mockMarkerMounts[key]); }); - const pins = [makePin({ Id: 'call-1', Type: 0, ImagePath: 'call', Title: 'Structure Fire' }), makePin({ Id: 'unit-1', Type: 1, Title: 'Engine 1' }), makePin({ Id: 'call-2', Type: 0, ImagePath: 'call', Title: 'MVA' })]; + // Pin ids as the Core map API sends them (`c` = call, `u` = unit); stores hold the bare call id. + const pins = [makePin({ Id: 'c101', Type: 0, ImagePath: 'call', Title: 'Structure Fire' }), makePin({ Id: 'u7', Type: 1, Title: 'Engine 1' }), makePin({ Id: 'c102', Type: 0, ImagePath: 'call', Title: 'MVA' })]; it('renders a marker per pin', () => { const { getByTestId, unmount } = render(); - expect(getByTestId('pin-call-1')).toBeTruthy(); - expect(getByTestId('pin-unit-1')).toBeTruthy(); - expect(getByTestId('pin-call-2')).toBeTruthy(); + expect(getByTestId('pin-c101')).toBeTruthy(); + expect(getByTestId('pin-u7')).toBeTruthy(); + expect(getByTestId('pin-c102')).toBeTruthy(); unmount(); }); it('renders the active call last so it stacks above other markers', () => { - const { toJSON, unmount } = render(); + const { toJSON, unmount } = render(); const tree = toJSON() as any[]; const ids = tree.map((node) => node.props.testID); - expect(ids[ids.length - 1]).toBe('pin-call-1'); + expect(ids[ids.length - 1]).toBe('pin-c101'); expect(ids).toHaveLength(3); unmount(); }); @@ -76,66 +77,87 @@ describe('MapPins', () => { // key so the pin whose active state changed remounts and re-attaches on top. it('remounts a pin when it becomes the active call so it re-attaches on top', () => { const { rerender, unmount } = render(); - expect(mockMarkerMounts['pin-call-1']).toBe(1); - expect(mockMarkerMounts['pin-unit-1']).toBe(1); + expect(mockMarkerMounts['pin-c101']).toBe(1); + expect(mockMarkerMounts['pin-u7']).toBe(1); - rerender(); + rerender(); // The newly active pin re-attached... - expect(mockMarkerMounts['pin-call-1']).toBe(2); + expect(mockMarkerMounts['pin-c101']).toBe(2); // ...and unrelated pins did not churn. - expect(mockMarkerMounts['pin-unit-1']).toBe(1); + expect(mockMarkerMounts['pin-u7']).toBe(1); unmount(); }); it('remounts both the old and new active pin when the active call changes mid-session', () => { - const { getByTestId, getAllByTestId, rerender, unmount } = render(); - expect(mockMarkerMounts['pin-call-1']).toBe(1); - expect(mockMarkerMounts['pin-call-2']).toBe(1); + const { getByTestId, getAllByTestId, rerender, unmount } = render(); + expect(mockMarkerMounts['pin-c101']).toBe(1); + expect(mockMarkerMounts['pin-c102']).toBe(1); - rerender(); + rerender(); // Both change active state, so both re-attach — the new active call ends up // attached last, above the one it replaced. - expect(mockMarkerMounts['pin-call-1']).toBe(2); - expect(mockMarkerMounts['pin-call-2']).toBe(2); - expect(mockMarkerMounts['pin-unit-1']).toBe(1); + expect(mockMarkerMounts['pin-c101']).toBe(2); + expect(mockMarkerMounts['pin-c102']).toBe(2); + expect(mockMarkerMounts['pin-u7']).toBe(1); // The highlight ring moved with it. expect(getAllByTestId('pin-active-ring')).toHaveLength(1); - expect(getByTestId('pin-call-2')).toBeTruthy(); + expect(getByTestId('pin-c102')).toBeTruthy(); unmount(); }); it('does not remount pins when an unrelated prop changes', () => { const onPinPress = jest.fn(); - const { rerender, unmount } = render(); + const { rerender, unmount } = render(); const before = { ...mockMarkerMounts }; - rerender(); + rerender(); expect(mockMarkerMounts).toEqual(before); unmount(); }); + // Realtime positions replace a moved pin with a new object at new coordinates. The marker must + // move in place: its id/key never include coordinates, so nothing is torn down and recreated. + it('moves a pin in place without remounting any marker when its position changes', () => { + const { rerender, getByTestId, unmount } = render(); + const before = { ...mockMarkerMounts }; + + const movedPins = [pins[0], { ...pins[1], Latitude: 41.5, Longitude: -73.25 }, pins[2]]; + rerender(); + + expect(mockMarkerMounts).toEqual(before); + expect(getByTestId('pin-u7').props.coordinate).toEqual([-73.25, 41.5]); + unmount(); + }); + it('highlights only the active call pin', () => { - const { getAllByTestId, unmount } = render(); + const { getAllByTestId, unmount } = render(); expect(getAllByTestId('pin-active-ring')).toHaveLength(1); unmount(); }); it('does not highlight a unit whose id happens to match the active call id', () => { - const unitPins = [makePin({ Id: 'shared-id', Type: 1, Title: 'Engine 1' })]; - const { queryByTestId, unmount } = render(); + const unitPins = [makePin({ Id: 'u101', Type: 1, Title: 'Engine 1' })]; + const { queryByTestId, unmount } = render(); expect(queryByTestId('pin-active-ring')).toBeNull(); unmount(); }); + it('highlights a call pin from a server that sends unprefixed ids', () => { + const legacyPins = [makePin({ Id: '101', Type: 0, ImagePath: 'call', Title: 'Structure Fire' })]; + const { getAllByTestId, unmount } = render(); + expect(getAllByTestId('pin-active-ring')).toHaveLength(1); + unmount(); + }); + it('keeps original order when there is no active call', () => { const { toJSON, unmount } = render(); const tree = toJSON() as any[]; const ids = tree.map((node) => node.props.testID); - expect(ids).toEqual(['pin-call-1', 'pin-unit-1', 'pin-call-2']); + expect(ids).toEqual(['pin-c101', 'pin-u7', 'pin-c102']); unmount(); }); }); diff --git a/src/components/maps/__tests__/pin-actions.test.tsx b/src/components/maps/__tests__/pin-actions.test.tsx index ba010862..8335c3ee 100644 --- a/src/components/maps/__tests__/pin-actions.test.tsx +++ b/src/components/maps/__tests__/pin-actions.test.tsx @@ -144,8 +144,9 @@ jest.mock('@/stores/toast/store', () => ({ }), })); +// Pin ids and types as the Core map API sends them (calls are Type 0 with a `c` prefix). const mockCallPin = { - Id: '123', + Id: 'c123', Title: 'Medical Emergency', Latitude: 40.7128, Longitude: -74.0060, @@ -158,12 +159,12 @@ const mockCallPin = { }; const mockUnitPin = { - Id: '456', + Id: 'u456', Title: 'Engine 1', Latitude: 40.7580, Longitude: -73.9855, ImagePath: 'engine_available', - Type: 2, + Type: 1, InfoWindowContent: 'Engine 1 available', Color: '#00ff00', zIndex: '1', @@ -368,6 +369,36 @@ describe('Pin Actions Integration Tests', () => { expect(mockOnClose).toHaveBeenCalledTimes(1); }); + it('should navigate with a legacy unprefixed call pin id unchanged', () => { + render( + + ); + + fireEvent.press(screen.getByText('map.view_call_details')); + + expect(mockRouter.push).toHaveBeenCalledWith('/call/123'); + }); + + it('should open POI details with the bare POI id', () => { + render( + + ); + + fireEvent.press(screen.getByText('map.view_poi_details')); + + expect(mockRouter.push).toHaveBeenCalledWith('/routes/poi/9'); + }); + it('should not show call details button for non-call pins', () => { render( = ({ isOpen, latitude, const coordinate: [number, number] = [longitude, latitude]; return ( - + = ({ isOpen, latitude, ) : null} - + ); }; diff --git a/src/components/maps/map-pins.tsx b/src/components/maps/map-pins.tsx index 967facbe..10181558 100644 --- a/src/components/maps/map-pins.tsx +++ b/src/components/maps/map-pins.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useMemo } from 'react'; import Mapbox from '@/components/maps/mapbox'; import { type MAP_ICONS } from '@/constants/map-icons'; +import { getPinEntityId } from '@/lib/map-pin-ids'; import { isPoiMarker } from '@/lib/poi-marker-utils'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; @@ -63,7 +64,7 @@ const MapPins: React.FC = ({ pins, onPinPress, activeCallId }) => // overlapping pins. const orderedPins = useMemo(() => { if (!activeCallId) return pins; - const activeIndex = pins.findIndex((pin) => isCallPin(pin) && pin.Id === activeCallId); + const activeIndex = pins.findIndex((pin) => isCallPin(pin) && getPinEntityId(pin) === activeCallId); if (activeIndex === -1) return pins; return [...pins.slice(0, activeIndex), ...pins.slice(activeIndex + 1), pins[activeIndex]]; }, [pins, activeCallId]); @@ -71,7 +72,7 @@ const MapPins: React.FC = ({ pins, onPinPress, activeCallId }) => return ( <> {orderedPins.map((pin) => { - const isActiveCall = activeCallId != null && isCallPin(pin) && pin.Id === activeCallId; + const isActiveCall = activeCallId != null && isCallPin(pin) && getPinEntityId(pin) === activeCallId; // Stacking order is fixed when the marker attaches (DOM insertion order // on web, imperative MarkerView attach on iOS), so reordering keyed // children alone updates the ring but never restacks. Folding the active diff --git a/src/components/maps/pin-detail-modal.tsx b/src/components/maps/pin-detail-modal.tsx index d538292d..9a8329b0 100644 --- a/src/components/maps/pin-detail-modal.tsx +++ b/src/components/maps/pin-detail-modal.tsx @@ -13,6 +13,7 @@ import { HStack } from '@/components/ui/hstack'; import { Pressable } from '@/components/ui/pressable'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; +import { getPinEntityId } from '@/lib/map-pin-ids'; import { openMapsWithDirections } from '@/lib/navigation'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { useLocationStore } from '@/stores/app/location-store'; @@ -56,15 +57,19 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC }; const handleViewCallDetails = () => { - if (isCallPin && pin.Id) { - router.push(`/call/${pin.Id}`); + const callId = getPinEntityId(pin); + + if (isCallPin && callId) { + router.push(`/call/${callId}`); onClose(); } }; const handleViewPoiDetails = () => { - if (isPoiPin && pin.Id) { - router.push(`/routes/poi/${pin.Id}` as any); + const poiId = getPinEntityId(pin); + + if (isPoiPin && poiId) { + router.push(`/routes/poi/${poiId}` as any); onClose(); } }; diff --git a/src/components/roles/__tests__/role-user-selection-modal.test.tsx b/src/components/roles/__tests__/role-user-selection-modal.test.tsx index d1658d95..86fbc7f5 100644 --- a/src/components/roles/__tests__/role-user-selection-modal.test.tsx +++ b/src/components/roles/__tests__/role-user-selection-modal.test.tsx @@ -25,10 +25,14 @@ jest.mock('nativewind', () => ({ // Mock Modal components jest.mock('@/components/ui/modal', () => ({ - Modal: ({ children, isOpen }: any) => { + Modal: ({ children, isOpen, useRNModal }: any) => { if (!isOpen) return null; const { View } = require('react-native'); - return {children}; + return ( + + {children} + + ); }, ModalBackdrop: () => null, ModalBody: ({ children }: any) => { @@ -177,6 +181,22 @@ describe('RoleUserSelectionModal', () => { expect(screen.getByTestId('role-user-selection-modal')).toBeTruthy(); }); + // The picker opens from inside the roles sheet, which is a native Modal. A portal overlay would + // render under that sheet's window, where it cannot be seen or tapped. + it('renders as a native modal so it stacks above the roles sheet', () => { + render( + + ); + + expect(screen.getByTestId('modal').props.accessibilityHint).toBe('native-modal'); + }); + it('shows the unassigned option', () => { render( ({ + useTranslation: () => ({ t: (key: string, defaultValue?: string) => defaultValue || key }), +})); +jest.mock('nativewind', () => ({ styled: (component: any) => component, useColorScheme: () => ({ colorScheme: 'light' }), cssInterop: jest.fn() })); +jest.mock('@/lib/logging', () => ({ logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() } })); +jest.mock('@/hooks/use-analytics', () => ({ useAnalytics: () => ({ trackEvent: jest.fn() }) })); + +jest.mock('@/components/ui/bottom-sheet', () => ({ + CustomBottomSheet: ({ children, isOpen }: any) => { + const { View } = require('react-native'); + return isOpen ? {children} : null; + }, +})); + +// Each role row offers one pick, so a test can create an unsaved change. +jest.mock('../role-assignment-item', () => ({ + RoleAssignmentItem: ({ role, onAssignUser }: any) => { + const { Pressable, Text } = require('react-native'); + return ( + onAssignUser('user2')}> + {role.Name} + + ); + }, +})); + +const mockShowToast = jest.fn(); +jest.mock('@/stores/toast/store', () => ({ useToastStore: { getState: () => ({ showToast: mockShowToast }) } })); + +jest.mock('@/stores/app/core-store', () => { + const activeUnit = { UnitId: 'unit1', Name: 'Engine 1' }; + return { useCoreStore: (selector: any) => selector({ activeUnit }) }; +}); + +const mockAssignRoles = jest.fn(); +const mockFetchRolesForUnit = jest.fn(async () => undefined); +const mockFetchAllForUnit = jest.fn(async () => undefined); +jest.mock('@/stores/roles/store', () => { + const state = { + roles: [ + { UnitRoleId: 'role1', Name: 'Captain', UnitId: 'unit1' }, + { UnitRoleId: 'role2', Name: 'Engineer', UnitId: 'unit1' }, + ], + unitRoleAssignments: [], + users: [ + { UserId: 'user1', FirstName: 'Ann', LastName: 'Lee' }, + { UserId: 'user2', FirstName: 'Bo', LastName: 'Ray' }, + ], + isLoading: false, + error: null, + }; + const useRolesStore: any = (selector: any) => selector(state); + useRolesStore.getState = () => ({ ...state, assignRoles: mockAssignRoles, fetchRolesForUnit: mockFetchRolesForUnit, fetchAllForUnit: mockFetchAllForUnit }); + return { useRolesStore }; +}); + +const pressSave = async () => { + await act(async () => { + fireEvent.press(screen.getByTestId('save-button')); + }); +}; + +describe('RolesBottomSheet saving', () => { + const onClose = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('closes with a success message when the save goes through', async () => { + mockAssignRoles.mockResolvedValue(undefined); + render(); + + fireEvent.press(screen.getByTestId('assign-role1')); + await pressSave(); + + expect(mockAssignRoles).toHaveBeenCalledWith(expect.objectContaining({ UnitId: 'unit1', Roles: expect.arrayContaining([{ RoleId: 'role1', UserId: 'user2', Name: '' }]) })); + expect(onClose).toHaveBeenCalledTimes(1); + expect(mockShowToast).toHaveBeenCalledWith('success', 'Role assignments saved successfully'); + expect(screen.queryByTestId('save-error-message')).toBeNull(); + }); + + it('reports a failed save inside the sheet and keeps the picks for a retry', async () => { + mockAssignRoles.mockRejectedValueOnce(new Error('Request failed with status code 500')); + render(); + + fireEvent.press(screen.getByTestId('assign-role1')); + await pressSave(); + + expect(screen.getByTestId('save-error-message')).toBeTruthy(); + expect(screen.getByText('Error saving role assignments')).toBeTruthy(); + expect(onClose).not.toHaveBeenCalled(); + expect(mockShowToast).not.toHaveBeenCalled(); + + // Retry sends the same picks, and success clears the error and closes. + mockAssignRoles.mockResolvedValueOnce(undefined); + await pressSave(); + + expect(mockAssignRoles).toHaveBeenCalledTimes(2); + expect(mockAssignRoles.mock.calls[1][0]).toEqual(mockAssignRoles.mock.calls[0][0]); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('clears the save error once the crew changes a pick', async () => { + mockAssignRoles.mockRejectedValueOnce(new Error('Network Error')); + render(); + + fireEvent.press(screen.getByTestId('assign-role1')); + await pressSave(); + expect(screen.getByTestId('save-error-message')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('assign-role2')); + + expect(screen.queryByTestId('save-error-message')).toBeNull(); + }); +}); diff --git a/src/components/roles/role-user-selection-modal.tsx b/src/components/roles/role-user-selection-modal.tsx index 9b80631a..1465f307 100644 --- a/src/components/roles/role-user-selection-modal.tsx +++ b/src/components/roles/role-user-selection-modal.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { Platform, Pressable, StyleSheet, View } from 'react-native'; +import { ModalToastHost } from '@/components/toast/toast-container'; import { Box } from '@/components/ui/box'; import { Divider } from '@/components/ui/divider'; import { HStack } from '@/components/ui/hstack'; @@ -122,8 +123,13 @@ export const RoleUserSelectionModal: React.FC = ({ [selectedUserId, isDark, handleSelect, t, currentAssignments, currentRoleId] ); + // useRNModal: this picker opens from inside the roles sheet, which is a native Modal + // (CustomBottomSheet) with its own window. A default gluestack Modal portals to the host at the + // app root instead — underneath that window, so the picker showed behind the sheet and could not + // be tapped. As a native Modal it presents on top of the sheet, and Android back closes just the + // picker rather than the whole sheet. return ( - + @@ -188,6 +194,8 @@ export const RoleUserSelectionModal: React.FC = ({ + {/* Native window (useRNModal) above the roles sheet: toasts must render in here to be seen. */} + ); }; diff --git a/src/components/roles/roles-bottom-sheet.tsx b/src/components/roles/roles-bottom-sheet.tsx index b47bf79f..7d9fac54 100644 --- a/src/components/roles/roles-bottom-sheet.tsx +++ b/src/components/roles/roles-bottom-sheet.tsx @@ -36,12 +36,16 @@ export const RolesBottomSheet: React.FC = ({ isOpen, onCl // Add state to track pending changes const [pendingAssignments, setPendingAssignments] = React.useState<{ roleId: string; userId?: string }[]>([]); const [isSaving, setIsSaving] = React.useState(false); + // Shown inside the sheet rather than as a toast: the sheet is a native Modal with its own window, + // and toasts draw in the app window underneath it, where a failure is easy to miss entirely. + const [saveError, setSaveError] = React.useState(null); React.useEffect(() => { if (isOpen && activeUnit) { useRolesStore.getState().fetchAllForUnit(activeUnit.UnitId); // Reset pending assignments when bottom sheet opens setPendingAssignments([]); + setSaveError(null); } }, [isOpen, activeUnit]); @@ -61,6 +65,7 @@ export const RolesBottomSheet: React.FC = ({ isOpen, onCl // Handle user assignment changes - auto-unassign from previous role when swapping const handleAssignUser = React.useCallback( (roleId: string, userId?: string) => { + setSaveError(null); setPendingAssignments((current) => { let updated = current.filter((a) => a.roleId !== roleId); // If assigning a user, check if they're currently in another role and unassign them @@ -97,6 +102,7 @@ export const RolesBottomSheet: React.FC = ({ isOpen, onCl if (!activeUnit) return; setIsSaving(true); + setSaveError(null); try { // Get all roles for this unit, allowing empty UserId for unassignments const allUnitRoles = filteredRoles @@ -134,7 +140,8 @@ export const RolesBottomSheet: React.FC = ({ isOpen, onCl error: err, }, }); - useToastStore.getState().showToast('error', t('roles.save_error', 'Error saving role assignments')); + // The sheet stays open with the picks intact, so Save retries them. + setSaveError(t('roles.save_error', 'Error saving role assignments')); } finally { setIsSaving(false); } @@ -214,6 +221,12 @@ export const RolesBottomSheet: React.FC = ({ isOpen, onCl )} + {saveError ? ( + + {saveError} + + ) : null} + - diff --git a/src/components/status/__tests__/gps-coordinate-duplication-fix.test.tsx b/src/components/status/__tests__/gps-coordinate-duplication-fix.test.tsx index 0320dec3..f6ff99b1 100644 --- a/src/components/status/__tests__/gps-coordinate-duplication-fix.test.tsx +++ b/src/components/status/__tests__/gps-coordinate-duplication-fix.test.tsx @@ -17,6 +17,9 @@ jest.mock('@/api/units/unitStatuses', () => ({ jest.mock('@/services/offline-event-manager.service', () => ({ offlineEventManager: { queueUnitStatusEvent: jest.fn(), + isDeviceOffline: jest.fn(() => false), + hasUndeliveredUnitStatuses: jest.fn(() => false), + deliverQueuedUnitStatuses: jest.fn(async () => true), }, })); diff --git a/src/components/status/__tests__/location-update-validation.test.tsx b/src/components/status/__tests__/location-update-validation.test.tsx index 1973ccdf..e9c17226 100644 --- a/src/components/status/__tests__/location-update-validation.test.tsx +++ b/src/components/status/__tests__/location-update-validation.test.tsx @@ -17,6 +17,9 @@ jest.mock('@/api/units/unitStatuses', () => ({ jest.mock('@/services/offline-event-manager.service', () => ({ offlineEventManager: { queueUnitStatusEvent: jest.fn(), + isDeviceOffline: jest.fn(() => false), + hasUndeliveredUnitStatuses: jest.fn(() => false), + deliverQueuedUnitStatuses: jest.fn(async () => true), }, })); diff --git a/src/components/status/__tests__/status-bottom-sheet-submission.test.tsx b/src/components/status/__tests__/status-bottom-sheet-submission.test.tsx new file mode 100644 index 00000000..c778bebc --- /dev/null +++ b/src/components/status/__tests__/status-bottom-sheet-submission.test.tsx @@ -0,0 +1,289 @@ +// Submission lifecycle of the root-mounted status sheet, driven through the real status stores so a +// request can outlive the sheet that started it — the path that left crews unable to submit a +// second status without force-closing the app. +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('@/hooks/use-keyboard-height', () => ({ useKeyboardHeight: () => 0 })); + +// Mirrors how gluestack dismisses: the backdrop only when closeOnOverlayClick allows it, Android back +// always (the sheet consumes the press), and the swipe handle only while it can receive touches. +jest.mock('@/components/ui/actionsheet', () => { + const mockReact = require('react'); + const { Pressable, View } = require('react-native'); + const MockContext = mockReact.createContext({ onClose: () => {}, closeOnOverlayClick: true }); + + return { + Actionsheet: ({ children, isOpen, onClose, closeOnOverlayClick = true }: any) => + isOpen + ? mockReact.createElement( + MockContext.Provider, + { value: { onClose, closeOnOverlayClick } }, + mockReact.createElement(View, { testID: 'actionsheet' }, children, mockReact.createElement(Pressable, { testID: 'hardware-back', onPress: onClose })) + ) + : null, + ActionsheetBackdrop: () => { + const { onClose, closeOnOverlayClick } = mockReact.useContext(MockContext); + return mockReact.createElement(Pressable, { testID: 'backdrop', onPress: () => (closeOnOverlayClick ? onClose() : undefined) }); + }, + ActionsheetContent: ({ children }: any) => mockReact.createElement(View, null, children), + ActionsheetDragIndicator: () => null, + ActionsheetDragIndicatorWrapper: ({ children, pointerEvents }: any) => { + const { onClose } = mockReact.useContext(MockContext); + return mockReact.createElement(Pressable, { testID: 'drag-handle', pointerEvents, onPress: () => (pointerEvents === 'none' ? undefined : onClose()) }, children); + }, + }; +}); + +jest.mock('../../ui/button', () => { + const mockReact = require('react'); + const { TouchableOpacity, Text } = require('react-native'); + return { + Button: ({ children, onPress, isDisabled }: any) => mockReact.createElement(TouchableOpacity, { onPress: isDisabled ? undefined : onPress, accessibilityState: { disabled: !!isDisabled } }, children), + ButtonText: ({ children }: any) => mockReact.createElement(Text, null, children), + }; +}); +jest.mock('../../ui/heading', () => ({ Heading: ({ children }: any) => require('react').createElement(require('react-native').Text, null, children) })); +jest.mock('../../ui/hstack', () => ({ HStack: ({ children }: any) => require('react').createElement(require('react-native').View, null, children) })); +jest.mock('../../ui/vstack', () => ({ VStack: ({ children }: any) => require('react').createElement(require('react-native').View, null, children) })); +jest.mock('../../ui/text', () => ({ Text: ({ children, testID }: any) => require('react').createElement(require('react-native').Text, { testID }, children) })); +jest.mock('../../ui/spinner', () => ({ Spinner: () => require('react').createElement(require('react-native').View, { testID: 'spinner' }) })); +jest.mock('../../ui/textarea', () => ({ + Textarea: ({ children }: any) => require('react').createElement(require('react-native').View, null, children), + TextareaInput: (props: any) => require('react').createElement(require('react-native').TextInput, props), +})); +jest.mock('nativewind', () => ({ useColorScheme: () => ({ colorScheme: 'light' }), styled: (component: any) => component, cssInterop: (component: any) => component })); +jest.mock('lucide-react-native', () => new Proxy({}, { get: () => () => null })); +jest.mock('@/lib/utils', () => ({ invertColor: () => '#000000' })); +jest.mock('@/lib/logging', () => ({ logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() } })); + +jest.mock('@/services/location-fix', () => ({ + acquireLocationFix: jest.fn(async () => ({ + outcome: 'acquired', + location: { coords: { latitude: 40.7128, longitude: -74.006, accuracy: 5, altitude: 10, altitudeAccuracy: 3, speed: 0, heading: 0 }, timestamp: 1700000000000 }, + })), + getLocationFixErrorMessage: jest.fn(() => 'fix-error'), + readRecentLocation: jest.fn(async () => null), +})); +jest.mock('@/services/offline-event-manager.service', () => ({ + offlineEventManager: { + initialize: jest.fn(), + queueUnitStatusEvent: jest.fn(() => 'queued-event'), + isDeviceOffline: jest.fn(() => false), + hasUndeliveredUnitStatuses: jest.fn(() => false), + deliverQueuedUnitStatuses: jest.fn(async () => true), + }, +})); +jest.mock('@/stores/app/location-store', () => ({ + useLocationStore: { getState: () => ({ latitude: null, longitude: null, accuracy: null, altitude: null, speed: null, heading: null, timestamp: null }) }, +})); +jest.mock('@/stores/roles/store', () => ({ useRolesStore: (selector: any) => selector({ unitRoleAssignments: [] }) })); +jest.mock('@/stores/calls/store', () => ({ useCallsStore: { setState: jest.fn(), getState: () => ({}) } })); + +const mockShowToast = jest.fn(); +jest.mock('@/stores/toast/store', () => ({ useToastStore: (selector: any) => selector({ showToast: mockShowToast }) })); + +jest.mock('@/api/dispatch/dispatch', () => ({ getSetUnitStatusData: jest.fn() })); +jest.mock('@/api/units/unitStatuses', () => ({ saveUnitStatus: jest.fn() })); + +jest.mock('@/stores/app/core-store', () => { + const { create } = require('zustand'); + const useCoreStore = create(() => ({ + activeUnit: { UnitId: 'unit-1', Name: 'Engine 1' }, + activeUnitStatus: null, + activeCallId: null, + setActiveCall: jest.fn(), + setActiveUnitWithFetch: jest.fn(() => Promise.resolve()), + activeStatuses: { + UnitType: '0', + Statuses: [ + { Id: 1, Text: 'Available', BColor: '#28a745', Gps: false, Note: 0, Detail: 0 }, + { Id: 2, Text: 'Responding', BColor: '#ffc107', Gps: false, Note: 0, Detail: 0 }, + { Id: 3, Text: 'On Scene', BColor: '#dc3545', Gps: true, Note: 0, Detail: 0 }, + ], + }, + })); + return { useCoreStore }; +}); + +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { getSetUnitStatusData } from '@/api/dispatch/dispatch'; +import { saveUnitStatus } from '@/api/units/unitStatuses'; +import { acquireLocationFix, readRecentLocation } from '@/services/location-fix'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useStatusBottomSheetStore } from '@/stores/status/store'; + +import { StatusBottomSheet } from '../status-bottom-sheet'; + +const mockSaveUnitStatus = saveUnitStatus as jest.Mock; + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +const statusById = (id: number) => (useCoreStore.getState() as any).activeStatuses.Statuses.find((status: { Id: number }) => status.Id === id); + +const flush = async () => { + await act(async () => { + await Promise.resolve(); + }); +}; + +const openWith = async (statusId: number) => { + act(() => useStatusBottomSheetStore.getState().setIsOpen(true, statusById(statusId))); + await flush(); +}; + +const pressSubmit = async () => { + await act(async () => { + fireEvent.press(screen.getByText('common.submit')); + }); + await flush(); +}; + +describe('StatusBottomSheet submission lifecycle', () => { + beforeEach(() => { + jest.clearAllMocks(); + useStatusBottomSheetStore.getState().reset(); + (getSetUnitStatusData as jest.Mock).mockResolvedValue({ Data: { Calls: [], Stations: [], DestinationPois: [], PoiTypes: [] } }); + mockSaveUnitStatus.mockResolvedValue({}); + }); + + it('submits two statuses back to back', async () => { + const { unmount } = render(); + + await openWith(1); + await pressSubmit(); + expect(useStatusBottomSheetStore.getState().isOpen).toBe(false); + + await openWith(2); + await pressSubmit(); + + expect(mockSaveUnitStatus).toHaveBeenCalledTimes(2); + expect(mockSaveUnitStatus).toHaveBeenNthCalledWith(1, expect.objectContaining({ Type: '1' })); + expect(mockSaveUnitStatus).toHaveBeenNthCalledWith(2, expect.objectContaining({ Type: '2' })); + expect(mockShowToast).toHaveBeenCalledTimes(2); + unmount(); + }); + + it('cannot be dismissed while a submission is in flight', async () => { + const save = deferred(); + mockSaveUnitStatus.mockReturnValueOnce(save.promise); + const { unmount } = render(); + + await openWith(1); + await pressSubmit(); + expect(screen.getByText('common.submitting')).toBeTruthy(); + + // Backdrop tap, Android back, and a swipe on the handle. + fireEvent.press(screen.getByTestId('backdrop')); + fireEvent.press(screen.getByTestId('hardware-back')); + fireEvent.press(screen.getByTestId('drag-handle')); + await flush(); + + expect(screen.getByTestId('drag-handle').props.pointerEvents).toBe('none'); + expect(useStatusBottomSheetStore.getState().isOpen).toBe(true); + expect(useStatusBottomSheetStore.getState().selectedStatus?.Id).toBe(1); + + await act(async () => { + save.resolve({}); + }); + await flush(); + + expect(useStatusBottomSheetStore.getState().isOpen).toBe(false); + expect(mockShowToast).toHaveBeenCalledWith('success', 'status.status_saved_successfully'); + unmount(); + }); + + it('can be dismissed again once the submission settles', async () => { + mockSaveUnitStatus.mockRejectedValueOnce(Object.assign(new Error('Request failed with status code 500'), { isAxiosError: true, response: { status: 500 } })); + const { unmount } = render(); + + await openWith(1); + await pressSubmit(); + + expect(mockShowToast).toHaveBeenCalledWith('error', 'status.failed_to_save_status'); + expect(useStatusBottomSheetStore.getState().isOpen).toBe(true); + expect(screen.getByTestId('drag-handle').props.pointerEvents).toBe('auto'); + + fireEvent.press(screen.getByTestId('backdrop')); + await flush(); + + expect(useStatusBottomSheetStore.getState().isOpen).toBe(false); + unmount(); + }); + + it('does not let a request from a closed sheet block or close the next one', async () => { + const firstSave = deferred(); + const secondSave = deferred(); + mockSaveUnitStatus.mockReturnValueOnce(firstSave.promise).mockReturnValueOnce(secondSave.promise); + const { unmount } = render(); + + await openWith(1); + await pressSubmit(); + + // Closed from outside the sheet (e.g. the app resetting state) while the save is in flight. + act(() => useStatusBottomSheetStore.getState().reset()); + await flush(); + + await openWith(2); + expect(screen.queryByText('common.submitting')).toBeNull(); + + await pressSubmit(); + expect(mockSaveUnitStatus).toHaveBeenCalledTimes(2); + + // The first request lands while the second sheet is still waiting on its own. + await act(async () => { + firstSave.resolve({}); + }); + await flush(); + + expect(useStatusBottomSheetStore.getState().isOpen).toBe(true); + expect(useStatusBottomSheetStore.getState().selectedStatus?.Id).toBe(2); + expect(mockShowToast).not.toHaveBeenCalled(); + + await act(async () => { + secondSave.resolve({}); + }); + await flush(); + + expect(useStatusBottomSheetStore.getState().isOpen).toBe(false); + expect(mockShowToast).toHaveBeenCalledTimes(1); + expect(mockShowToast).toHaveBeenCalledWith('success', 'status.status_saved_successfully'); + unmount(); + }); + + it('tells the crew the status is queued when it could not be sent yet', async () => { + mockSaveUnitStatus.mockRejectedValueOnce(Object.assign(new Error('Network Error'), { isAxiosError: true, code: 'ERR_NETWORK', request: {} })); + const { unmount } = render(); + + await openWith(1); + await pressSubmit(); + + expect(mockShowToast).toHaveBeenCalledWith('warning', 'status.status_queued'); + expect(mockShowToast).not.toHaveBeenCalledWith('success', expect.anything()); + expect(useStatusBottomSheetStore.getState().isOpen).toBe(false); + unmount(); + }); + + it('only waits on a live GPS fix for a status that requires one', async () => { + const { unmount } = render(); + + await openWith(1); + await pressSubmit(); + expect(acquireLocationFix).not.toHaveBeenCalled(); + expect(readRecentLocation).toHaveBeenCalledTimes(1); + + await openWith(3); + await pressSubmit(); + expect(acquireLocationFix).toHaveBeenCalledTimes(1); + expect(mockSaveUnitStatus).toHaveBeenLastCalledWith(expect.objectContaining({ Type: '3', Latitude: '40.7128', Longitude: '-74.006' })); + unmount(); + }); +}); diff --git a/src/components/status/__tests__/status-bottom-sheet.test.tsx b/src/components/status/__tests__/status-bottom-sheet.test.tsx index 2138aa8d..b5552af3 100644 --- a/src/components/status/__tests__/status-bottom-sheet.test.tsx +++ b/src/components/status/__tests__/status-bottom-sheet.test.tsx @@ -221,6 +221,10 @@ jest.mock('@/services/location-fix', () => ({ }, })), getLocationFixErrorMessage: jest.fn((outcome: string) => `fix-error:${outcome}`), + readRecentLocation: jest.fn(async () => ({ + coords: { latitude: 40.7128, longitude: -74.006, accuracy: 5, altitude: 10, altitudeAccuracy: 3, speed: 0, heading: 0 }, + timestamp: 1700000000000, + })), })); jest.mock('@/lib/logging', () => ({ @@ -3927,14 +3931,17 @@ describe('StatusBottomSheet', () => { expect(mockSaveUnitStatus).toHaveBeenCalledWith(expect.objectContaining({ Latitude: '40.7128', Longitude: '-74.006' })); }); - it('still submits a status that does not require GPS when no fix can be taken', async () => { - mockAcquireLocationFix.mockResolvedValueOnce({ outcome: 'unavailable', location: null }); + it('does not wait on a live fix for a status that does not require GPS', async () => { + const mockReadRecentLocation = require('@/services/location-fix').readRecentLocation as jest.Mock; + mockReadRecentLocation.mockResolvedValueOnce(null); buildGpsRequiredStore({ ...gpsRequiredStatus, Gps: false }); render(); fireEvent.press(screen.getByText('Submit')); await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalled()); + expect(mockReadRecentLocation).toHaveBeenCalled(); + expect(mockAcquireLocationFix).not.toHaveBeenCalled(); }); }); diff --git a/src/components/status/__tests__/status-gps-integration-working.test.tsx b/src/components/status/__tests__/status-gps-integration-working.test.tsx index b187881b..92142363 100644 --- a/src/components/status/__tests__/status-gps-integration-working.test.tsx +++ b/src/components/status/__tests__/status-gps-integration-working.test.tsx @@ -109,6 +109,9 @@ jest.mock('@/api/units/unitStatuses', () => ({ jest.mock('@/services/offline-event-manager.service', () => ({ offlineEventManager: { queueUnitStatusEvent: jest.fn(), + isDeviceOffline: jest.fn(() => false), + hasUndeliveredUnitStatuses: jest.fn(() => false), + deliverQueuedUnitStatuses: jest.fn(async () => true), }, })); diff --git a/src/components/status/__tests__/status-gps-integration.test.tsx b/src/components/status/__tests__/status-gps-integration.test.tsx index 554efc4b..68e19e94 100644 --- a/src/components/status/__tests__/status-gps-integration.test.tsx +++ b/src/components/status/__tests__/status-gps-integration.test.tsx @@ -18,6 +18,9 @@ jest.mock('@/api/units/unitStatuses', () => ({ jest.mock('@/services/offline-event-manager.service', () => ({ offlineEventManager: { queueUnitStatusEvent: jest.fn(), + isDeviceOffline: jest.fn(() => false), + hasUndeliveredUnitStatuses: jest.fn(() => false), + deliverQueuedUnitStatuses: jest.fn(async () => true), }, })); diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx index 1928f2de..aa3dba50 100644 --- a/src/components/status/status-bottom-sheet.tsx +++ b/src/components/status/status-bottom-sheet.tsx @@ -1,3 +1,4 @@ +import type { LocationObject } from 'expo-location'; import { ArrowLeft, ArrowRight, Check } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React from 'react'; @@ -15,7 +16,7 @@ import { DestinationEntityTypes } from '@/models/v4/destinations/destinationEnti import { type GroupResultData } from '@/models/v4/groups/groupsResultData'; import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; import { SaveUnitStatusInput, SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; -import { acquireLocationFix, getLocationFixErrorMessage } from '@/services/location-fix'; +import { acquireLocationFix, getLocationFixErrorMessage, readRecentLocation } from '@/services/location-fix'; import { offlineEventManager } from '@/services/offline-event-manager.service'; import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; @@ -106,6 +107,11 @@ export const StatusBottomSheet = () => { const keyboardHeight = useKeyboardHeight(); const [selectedTab, setSelectedTab] = React.useState('call'); const [isSubmitting, setIsSubmitting] = React.useState(false); + // The submission this sheet is waiting on, if any. The sheet is mounted once at the root, so a + // request from an earlier open can outlive it; every open/close clears this (see the isOpen effect + // below) so such a request can neither hold the new sheet on "Submitting" nor close it on landing. + const pendingSubmissionRef = React.useRef(null); + const submissionCounterRef = React.useRef(0); const showToast = useToastStore((state) => state.showToast); React.useEffect(() => { @@ -279,6 +285,12 @@ export const StatusBottomSheet = () => { } }, [isOpen]); + React.useEffect(() => { + // Detach from any submission still in flight: it no longer owns this sheet. + pendingSubmissionRef.current = null; + setIsSubmitting(false); + }, [isOpen]); + // The keyboard padding on ActionsheetContent reserves the covered strip, but it // doesn't move the note field there — if the field sits below the fold it stays // hidden under the keyboard. The note and its submit button are the last content @@ -431,6 +443,12 @@ export const StatusBottomSheet = () => { ); const handleClose = () => { + // Backdrop taps, Android back and swipe-down all land here. Closing mid-submit would not cancel + // the save, and the crew would reopen onto a sheet still tied to it — so, like Cancel, it waits. + if (pendingSubmissionRef.current !== null) { + return; + } + reset(); }; @@ -538,10 +556,16 @@ export const StatusBottomSheet = () => { const handleSubmit = React.useCallback(async () => { // Submitting before the destination data lands would send RespondingTo '0' and drop the call. - if (isSubmitting || !selectedStatus || !activeUnit || isAwaitingDestinationData) { + // The ref (not isSubmitting) guards re-entry so a double tap within one frame cannot send twice. + if (pendingSubmissionRef.current !== null || !selectedStatus || !activeUnit || isAwaitingDestinationData) { return; } + const submissionId = ++submissionCounterRef.current; + pendingSubmissionRef.current = submissionId; + // False once the sheet has been closed or reopened under this submission (see the isOpen effect). + const isCurrentSubmission = () => pendingSubmissionRef.current === submissionId; + try { setIsSubmitting(true); @@ -566,23 +590,38 @@ export const StatusBottomSheet = () => { input.RespondingToType = DestinationEntityTypes.Poi; } - // Take a fix at submission time rather than trusting whatever the watcher last left in the - // store. The watcher only runs once a unit is selected and permission was granted, the store - // is never persisted, and permission can be revoked from the OS at any point — so a cached - // read is not evidence of anything. It is also the only honest way to enforce a status whose - // custom-state definition sets GpsRequired: checking the cache would wave through a status - // carrying coordinates from hours ago. - const fix = await acquireLocationFix(); + // A status whose custom-state definition sets GpsRequired takes a live fix at submission time: + // the watcher only runs once a unit is selected and permission was granted, the store is never + // persisted, and permission can be revoked from the OS at any point — so checking the cache + // would wave through a status carrying coordinates from hours ago. + // + // Every other status takes what the OS already knows. A live fix can take the full fix timeout + // indoors, and holding each status on "Submitting" for coordinates it does not require is what + // pushed crews to dismiss the sheet and force-close the app. + let location: LocationObject | null; + if (selectedStatus.Gps) { + const fix = await acquireLocationFix(); + if (!isCurrentSubmission()) { + return; + } - if (fix.outcome !== 'acquired' && selectedStatus.Gps) { - showToast('error', getLocationFixErrorMessage(fix.outcome)); - return; + if (fix.outcome !== 'acquired') { + showToast('error', getLocationFixErrorMessage(fix.outcome)); + return; + } + + location = fix.location; + } else { + location = await readRecentLocation(); + if (!isCurrentSubmission()) { + return; + } } - // Read the latest GPS fix imperatively (no render subscription), preferring the fix we just - // took and falling back to the watcher for a status that does not require one. + // Read the latest GPS fix imperatively (no render subscription), preferring the position we + // just read and falling back to the watcher. const cached = useLocationStore.getState(); - const coords = fix.location?.coords ?? null; + const coords = location?.coords ?? null; const latitude = coords?.latitude ?? cached.latitude; const longitude = coords?.longitude ?? cached.longitude; const accuracy = coords?.accuracy ?? cached.accuracy; @@ -590,7 +629,7 @@ export const StatusBottomSheet = () => { const altitudeAccuracy = coords?.altitudeAccuracy ?? null; const speed = coords?.speed ?? cached.speed; const heading = coords?.heading ?? cached.heading; - const timestamp = fix.location?.timestamp ?? cached.timestamp; + const timestamp = location?.timestamp ?? cached.timestamp; if (latitude !== null && longitude !== null) { input.Latitude = latitude.toString(); @@ -621,35 +660,40 @@ export const StatusBottomSheet = () => { setActiveCall(destination.call.CallId); } - await saveUnitStatus(input); - showToast('success', t('status.status_saved_successfully')); + const outcome = await saveUnitStatus(input); + + // The sheet was closed or reopened while this was in flight: it now belongs to another status, + // so this result must not toast over it or reset it. + if (!isCurrentSubmission()) { + logger.info({ + message: 'Unit status submission settled after its sheet moved on', + context: { unitId: input.Id, statusType: input.Type, outcome }, + }); + return; + } + + if (outcome === 'queued') { + showToast('warning', t('status.status_queued')); + } else { + showToast('success', t('status.status_saved_successfully')); + } reset(); } catch (error) { logger.error({ message: 'Failed to save unit status', context: { error }, }); - showToast('error', t('status.failed_to_save_status')); + + if (isCurrentSubmission()) { + showToast('error', t('status.failed_to_save_status')); + } } finally { - setIsSubmitting(false); + if (isCurrentSubmission()) { + pendingSubmissionRef.current = null; + setIsSubmitting(false); + } } - }, [ - activeCallId, - activeUnit, - effectiveDestination, - getStatusId, - isAwaitingDestinationData, - isSubmitting, - note, - reset, - saveUnitStatus, - selectedStatus, - setActiveCall, - shouldShowDestinationStep, - showToast, - t, - unitRoleAssignments, - ]); + }, [activeCallId, activeUnit, effectiveDestination, getStatusId, isAwaitingDestinationData, note, reset, saveUnitStatus, selectedStatus, setActiveCall, shouldShowDestinationStep, showToast, t, unitRoleAssignments]); // True while the default call is still to be applied (list loading, or loaded but the auto-select // effect has not run yet) — "No destination" must not flash as the choice in that window. @@ -864,14 +908,19 @@ export const StatusBottomSheet = () => { const showPois = destinationTabs.includes('poi') && (!shouldShowDestinationTabs || selectedTab === 'poi'); return ( - + // While a submission is in flight the sheet stays put (handleClose also refuses). Android back is + // left enabled on purpose: the sheet consumes the press and ignores it, where disabling it would + // let the press fall through and navigate the screen underneath. + {/* The sheet renders inside a native Modal, so keyboard-controller's window-bound avoidance never moves it — padding by the keyboard height reserves the covered strip instead. shrink on the column lets the step content compress into the remaining space so its scrollview actually scrolls rather than Yoga clipping it. */} - + {/* The swipe-down gesture slides the sheet away before it asks to close, so it cannot be + refused afterwards — take it out of reach while submitting instead. */} + diff --git a/src/components/toast/__tests__/toast-container.test.tsx b/src/components/toast/__tests__/toast-container.test.tsx index 2d8d8638..d337702c 100644 --- a/src/components/toast/__tests__/toast-container.test.tsx +++ b/src/components/toast/__tests__/toast-container.test.tsx @@ -1,14 +1,9 @@ -import { render } from '@testing-library/react-native'; +import { act, render, screen } from '@testing-library/react-native'; import React from 'react'; import { useToastStore } from '@/stores/toast/store'; -import { ToastContainer } from '../toast-container'; - -// Mock the toast store -jest.mock('@/stores/toast/store', () => ({ - useToastStore: jest.fn(), -})); +import { ModalToastHost, ToastContainer } from '../toast-container'; // Mock react-native-safe-area-context jest.mock('react-native-safe-area-context', () => ({ @@ -33,29 +28,28 @@ jest.mock('../toast', () => ({ }, })); -describe('ToastContainer', () => { - const mockUseToastStore = useToastStore as jest.MockedFunction; +const setToasts = (toasts: { id: string; type: 'success' | 'error' | 'warning' | 'info' | 'muted'; message: string; title?: string }[]) => { + act(() => useToastStore.setState({ toasts })); +}; +describe('ToastContainer', () => { beforeEach(() => { - jest.clearAllMocks(); + useToastStore.setState({ toasts: [], modalHosts: [] }); }); it('renders nothing when no toasts are present', () => { - mockUseToastStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ toasts: [] }) : { toasts: [] }); - const { queryByTestId } = render(); + expect(queryByTestId('toast-host')).toBeNull(); expect(queryByTestId('toast-success')).toBeNull(); expect(queryByTestId('toast-error')).toBeNull(); }); it('renders toasts when they are present in the store', () => { - const mockToasts = [ - { id: '1', type: 'success' as const, message: 'Success message' }, - { id: '2', type: 'error' as const, message: 'Error message', title: 'Error Title' }, - ]; - - mockUseToastStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ toasts: mockToasts }) : { toasts: mockToasts }); + setToasts([ + { id: '1', type: 'success', message: 'Success message' }, + { id: '2', type: 'error', message: 'Error message', title: 'Error Title' }, + ]); const { getByTestId } = render(); @@ -64,15 +58,70 @@ describe('ToastContainer', () => { }); it('renders toast with title and message correctly', () => { - const mockToasts = [ - { id: '1', type: 'warning' as const, message: 'Warning message', title: 'Warning Title' }, - ]; - - mockUseToastStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ toasts: mockToasts }) : { toasts: mockToasts }); + setToasts([{ id: '1', type: 'warning', message: 'Warning message', title: 'Warning Title' }]); const { getByTestId } = render(); const toastElement = getByTestId('toast-warning'); expect(toastElement.props.children).toEqual(['Warning Title: ', 'Warning message']); }); -}); \ No newline at end of file + + it('draws above gluestack overlays, which sit at zIndex 9999 on web', () => { + setToasts([{ id: '1', type: 'error', message: 'Could not save' }]); + + render(); + + const layerStyle = [screen.getByTestId('toast-host').props.style].flat(Infinity).reduce((merged: Record, style) => ({ ...merged, ...(style as object) }), {}); + expect(layerStyle.zIndex).toBeGreaterThan(9999); + expect(layerStyle.top).toBe(44 + 70); + }); + + // A native Modal is its own window above the app, so the app-window host cannot be seen while one + // is open; toasts have to render inside the topmost modal window instead. + describe('inside native Modal windows', () => { + const App = ({ openModals }: { openModals: number }) => ( + <> + + {openModals >= 1 ? : null} + {openModals >= 2 ? : null} + + ); + + it('moves toasts into the modal while it is open and back to the app when it closes', () => { + setToasts([{ id: '1', type: 'error', message: 'Error saving role assignments' }]); + const { rerender } = render(); + expect(screen.getByTestId('toast-host')).toBeTruthy(); + + rerender(); + + expect(screen.queryByTestId('toast-host')).toBeNull(); + expect(screen.getAllByTestId('modal-toast-host')).toHaveLength(1); + expect(screen.getAllByTestId('toast-error')).toHaveLength(1); + + rerender(); + + expect(screen.getByTestId('toast-host')).toBeTruthy(); + expect(screen.queryByTestId('modal-toast-host')).toBeNull(); + expect(useToastStore.getState().modalHosts).toEqual([]); + }); + + it('renders in the most recently opened modal only', () => { + setToasts([{ id: '1', type: 'info', message: 'Heads up' }]); + const { rerender } = render(); + const sheetHostId = useToastStore.getState().modalHosts[0]; + + rerender(); + + expect(useToastStore.getState().modalHosts).toHaveLength(2); + expect(screen.getAllByTestId('modal-toast-host')).toHaveLength(1); + expect(screen.getAllByTestId('toast-info')).toHaveLength(1); + + // Closing the picker hands toasts back to the sheet underneath it. + rerender(); + + expect(useToastStore.getState().modalHosts).toEqual([sheetHostId]); + expect(screen.getAllByTestId('modal-toast-host')).toHaveLength(1); + expect(screen.queryByTestId('toast-host')).toBeNull(); + }); + }); +}); diff --git a/src/components/toast/toast-container.tsx b/src/components/toast/toast-container.tsx index 3d063244..58d625a1 100644 --- a/src/components/toast/toast-container.tsx +++ b/src/components/toast/toast-container.tsx @@ -6,19 +6,56 @@ import { VStack } from '@/components/ui/vstack'; import { useToastStore } from '../../stores/toast/store'; import { ToastMessage } from './toast'; -export const ToastContainer: React.FC = () => { +// gluestack overlays (Actionsheet, Modal, Drawer…) render at zIndex 9999 on web; toasts must clear them. +const TOAST_LAYER_Z_INDEX = 10000; + +interface ToastContainerProps { + /** + * 'root' is the app-window host, mounted once in the root layout above every portal overlay. + * 'modal' is a host inside a native Modal window (use ModalToastHost); while one is mounted the + * root host yields to the most recently opened one, since the app window is underneath it. + */ + placement?: 'root' | 'modal'; +} + +export const ToastContainer: React.FC = ({ placement = 'root' }) => { + const modalHostId = React.useId(); + const isModalHost = placement === 'modal'; + const registerModalHost = useToastStore((state) => state.registerModalHost); + const unregisterModalHost = useToastStore((state) => state.unregisterModalHost); + const isActiveHost = useToastStore((state) => (isModalHost ? state.modalHosts[state.modalHosts.length - 1] === modalHostId : state.modalHosts.length === 0)); const toasts = useToastStore((state) => state.toasts); const insets = useSafeAreaInsets(); + React.useEffect(() => { + if (!isModalHost) { + return; + } + + registerModalHost(modalHostId); + return () => unregisterModalHost(modalHostId); + }, [isModalHost, modalHostId, registerModalHost, unregisterModalHost]); + + if (!isActiveHost || toasts.length === 0) { + return null; + } + // Position below status bar and navigation header // Use a larger offset to ensure toasts appear below any navigation content const topOffset = insets.top + 70; // Status bar height + generous navigation header height return ( - + {toasts.map((toast) => ( ))} ); }; + +/** + * Toast host for a native Modal window (a native Modal is its own window, so toasts drawn in the app + * window sit underneath it). Render it as the last child of the Modal's content so it draws on top. + * NativeModal includes one already. + */ +export const ModalToastHost: React.FC = () => ; diff --git a/src/components/ui/alert-dialog/index.tsx b/src/components/ui/alert-dialog/index.tsx index 08021c3e..ccd10908 100644 --- a/src/components/ui/alert-dialog/index.tsx +++ b/src/components/ui/alert-dialog/index.tsx @@ -94,7 +94,6 @@ const AlertDialogContent = React.forwardRef + ); } diff --git a/src/components/ui/menu/index.tsx b/src/components/ui/menu/index.tsx index c3e806d7..89d8142c 100644 --- a/src/components/ui/menu/index.tsx +++ b/src/components/ui/menu/index.tsx @@ -118,7 +118,6 @@ const Menu = React.forwardRef, IMenuProps>(fun ref={ref} entering={ZoomIn.duration(200).withInitialValues({ transform: [{ scale: 0.9 }], - opacity: 0, })} exiting={FadeOut.duration(200)} {...props} diff --git a/src/hooks/__tests__/use-map-live-locations.test.ts b/src/hooks/__tests__/use-map-live-locations.test.ts new file mode 100644 index 00000000..a272d0ec --- /dev/null +++ b/src/hooks/__tests__/use-map-live-locations.test.ts @@ -0,0 +1,281 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { useState } from 'react'; + +import { type LiveLocation } from '@/lib/live-locations'; +import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; +import { useSignalRStore } from '@/stores/signalr/signalr-store'; + +import { applyLiveLocationsSince, UNKNOWN_PIN_REFRESH_COOLDOWN_MS, UNKNOWN_PIN_REFRESH_DELAY_MS, useMapLiveLocations } from '../use-map-live-locations'; + +// A real zustand store with just the slice the hook reads, so selector subscriptions behave as in the app. +jest.mock('@/stores/signalr/signalr-store', () => { + const { create } = jest.requireActual('zustand'); + return { useSignalRStore: create(() => ({ liveLocations: {}, geolocationJoinCount: 0 })) }; +}); + +jest.mock('@/lib/logging', () => ({ + logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +const makePin = (overrides: Partial): MapMakerInfoData => ({ + Id: 'u1', + Longitude: -74.0, + Latitude: 40.7, + Title: 'Pin', + zIndex: 0, + ImagePath: 'engine_available', + InfoWindowContent: '', + Color: '', + Type: 1, + Marker: '', + PoiImage: '', + ...overrides, +}); + +const makeLocation = (overrides: Partial): LiveLocation => ({ + pinId: 'u1', + latitude: 41, + longitude: -73, + timestamp: null, + receivedAt: Date.now(), + ...overrides, +}); + +const push = (location: LiveLocation) => { + act(() => { + useSignalRStore.setState((state: { liveLocations: Record }) => ({ liveLocations: { ...state.liveLocations, [location.pinId]: location } })); + }); +}; + +const setJoinCount = (geolocationJoinCount: number) => { + act(() => { + useSignalRStore.setState({ geolocationJoinCount }); + }); +}; + +let mockRenderCount = 0; + +const useHarness = (initialPins: MapMakerInfoData[], requestRefresh?: () => void) => { + mockRenderCount += 1; + const [pins, setPins] = useState(initialPins); + useMapLiveLocations(pins, setPins, requestRefresh); + return { pins, setPins }; +}; + +describe('useMapLiveLocations', () => { + const unitPin = makePin({ Id: 'u1', Type: 1, Latitude: 40, Longitude: -74 }); + const personPin = makePin({ Id: 'pABC', Type: 3, Latitude: 30, Longitude: -80 }); + const callPin = makePin({ Id: 'c5', Type: 0, Latitude: 20, Longitude: -90 }); + const initialPins = [callPin, unitPin, personPin]; + + beforeEach(() => { + jest.useFakeTimers(); + useSignalRStore.setState({ liveLocations: {}, geolocationJoinCount: 0 }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('moves a unit pin in place when its live location arrives', () => { + const { result, unmount } = renderHook(() => useHarness(initialPins)); + + push(makeLocation({ pinId: 'u1', latitude: 41.25, longitude: -73.5 })); + + expect(result.current.pins).toHaveLength(3); + expect(result.current.pins[1]).toEqual({ ...unitPin, Latitude: 41.25, Longitude: -73.5 }); + // Pins that did not move keep their identity so their memoized markers do not re-render. + expect(result.current.pins[0]).toBe(callPin); + expect(result.current.pins[2]).toBe(personPin); + unmount(); + }); + + it('moves personnel pins matched case-insensitively', () => { + const { result, unmount } = renderHook(() => useHarness(initialPins)); + + push(makeLocation({ pinId: 'pabc', latitude: 31, longitude: -81 })); + + expect(result.current.pins[2]).toEqual({ ...personPin, Latitude: 31, Longitude: -81 }); + unmount(); + }); + + it('does not re-render the map for pushes that move nothing', () => { + const { unmount } = renderHook(() => useHarness(initialPins, jest.fn())); + const rendersAfterMount = mockRenderCount; + + // Same position as the pin, and a pin the map does not have. + push(makeLocation({ pinId: 'u1', latitude: 40, longitude: -74 })); + push(makeLocation({ pinId: 'u99' })); + + expect(mockRenderCount).toBe(rendersAfterMount); + + push(makeLocation({ pinId: 'u1', latitude: 40.5, longitude: -74 })); + expect(mockRenderCount).toBe(rendersAfterMount + 1); + unmount(); + }); + + it('keeps the same pins array when a push does not move anything', () => { + const { result, unmount } = renderHook(() => useHarness(initialPins)); + const before = result.current.pins; + + push(makeLocation({ pinId: 'u1', latitude: 40, longitude: -74 })); + + expect(result.current.pins).toBe(before); + unmount(); + }); + + it('does not apply positions that were already held when the map mounted', () => { + useSignalRStore.setState({ liveLocations: { u1: makeLocation({ pinId: 'u1', latitude: 45 }) } }); + + const { result, unmount } = renderHook(() => useHarness(initialPins)); + + expect(result.current.pins).toBe(initialPins); + unmount(); + }); + + it('does not roll a refetched snapshot back to an older live position', () => { + const { result, unmount } = renderHook(() => useHarness(initialPins)); + + push(makeLocation({ pinId: 'u1', latitude: 41 })); + expect(result.current.pins[1].Latitude).toBe(41); + + // A later REST refetch replaces the pins with a newer position for the unit. + const refetched = [callPin, makePin({ Id: 'u1', Type: 1, Latitude: 42, Longitude: -74 }), personPin]; + act(() => { + result.current.setPins(refetched); + }); + + expect(result.current.pins).toBe(refetched); + + // An unrelated push must not re-apply the older unit position either. + push(makeLocation({ pinId: 'pabc', latitude: 31 })); + expect(result.current.pins[1].Latitude).toBe(42); + expect(result.current.pins[2].Latitude).toBe(31); + unmount(); + }); + + it('never adds a pin and requests one coalesced refetch for pins missing from the map', () => { + const requestRefresh = jest.fn(); + const { result, unmount } = renderHook(() => useHarness(initialPins, requestRefresh)); + + push(makeLocation({ pinId: 'u99' })); + push(makeLocation({ pinId: 'u98' })); + push(makeLocation({ pinId: 'pdef' })); + + expect(result.current.pins).toBe(initialPins); + expect(requestRefresh).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(UNKNOWN_PIN_REFRESH_DELAY_MS); + }); + + expect(requestRefresh).toHaveBeenCalledTimes(1); + unmount(); + }); + + it('lets each unknown pin request a refetch at most once per cooldown', () => { + const requestRefresh = jest.fn(); + const { unmount } = renderHook(() => useHarness(initialPins, requestRefresh)); + + push(makeLocation({ pinId: 'u99', latitude: 10 })); + act(() => { + jest.advanceTimersByTime(UNKNOWN_PIN_REFRESH_DELAY_MS); + }); + expect(requestRefresh).toHaveBeenCalledTimes(1); + + // The viewer may not be allowed to see u99; its next pushes must not keep refetching. + push(makeLocation({ pinId: 'u99', latitude: 11 })); + push(makeLocation({ pinId: 'u99', latitude: 12 })); + act(() => { + jest.advanceTimersByTime(UNKNOWN_PIN_REFRESH_DELAY_MS * 2); + }); + expect(requestRefresh).toHaveBeenCalledTimes(1); + + act(() => { + jest.advanceTimersByTime(UNKNOWN_PIN_REFRESH_COOLDOWN_MS); + }); + push(makeLocation({ pinId: 'u99', latitude: 13 })); + act(() => { + jest.advanceTimersByTime(UNKNOWN_PIN_REFRESH_DELAY_MS); + }); + expect(requestRefresh).toHaveBeenCalledTimes(2); + unmount(); + }); + + it('does not treat pushes as unknown before the map has any pins', () => { + const requestRefresh = jest.fn(); + const { unmount } = renderHook(() => useHarness([], requestRefresh)); + + push(makeLocation({ pinId: 'u1' })); + act(() => { + jest.advanceTimersByTime(UNKNOWN_PIN_REFRESH_DELAY_MS); + }); + + expect(requestRefresh).not.toHaveBeenCalled(); + unmount(); + }); + + it('drops a pending unknown-pin refetch on unmount', () => { + const requestRefresh = jest.fn(); + const { unmount } = renderHook(() => useHarness(initialPins, requestRefresh)); + + push(makeLocation({ pinId: 'u99' })); + unmount(); + act(() => { + jest.advanceTimersByTime(UNKNOWN_PIN_REFRESH_DELAY_MS); + }); + + expect(requestRefresh).not.toHaveBeenCalled(); + }); + + it('requests a catch-up refetch when the hub re-joins, but not for the first join', () => { + const requestRefresh = jest.fn(); + const { unmount } = renderHook(() => useHarness(initialPins, requestRefresh)); + + setJoinCount(1); + expect(requestRefresh).not.toHaveBeenCalled(); + + setJoinCount(2); + expect(requestRefresh).toHaveBeenCalledTimes(1); + + setJoinCount(3); + expect(requestRefresh).toHaveBeenCalledTimes(2); + unmount(); + }); + + it('requests a catch-up refetch for a re-join after mounting on an already joined hub', () => { + useSignalRStore.setState({ geolocationJoinCount: 1 }); + const requestRefresh = jest.fn(); + const { unmount } = renderHook(() => useHarness(initialPins, requestRefresh)); + + expect(requestRefresh).not.toHaveBeenCalled(); + + setJoinCount(2); + expect(requestRefresh).toHaveBeenCalledTimes(1); + unmount(); + }); +}); + +describe('applyLiveLocationsSince', () => { + it('re-applies only positions received at or after the fetch started', () => { + useSignalRStore.setState({ + liveLocations: { + u1: makeLocation({ pinId: 'u1', latitude: 45, receivedAt: 1000 }), + u2: makeLocation({ pinId: 'u2', latitude: 46, receivedAt: 3000 }), + }, + }); + const pins = [makePin({ Id: 'u1', Type: 1, Latitude: 40 }), makePin({ Id: 'u2', Type: 1, Latitude: 40 })]; + + const result = applyLiveLocationsSince(pins, 2000); + + expect(result[0]).toBe(pins[0]); + expect(result[1].Latitude).toBe(46); + }); + + it('returns the fetched array untouched when nothing newer was pushed', () => { + useSignalRStore.setState({ liveLocations: { u1: makeLocation({ pinId: 'u1', latitude: 45, receivedAt: 1000 }) } }); + const pins = [makePin({ Id: 'u1', Type: 1, Latitude: 40 })]; + + expect(applyLiveLocationsSince(pins, 2000)).toBe(pins); + }); +}); diff --git a/src/hooks/__tests__/use-map-signalr-updates.test.ts b/src/hooks/__tests__/use-map-signalr-updates.test.ts index 23d0c044..0d28a643 100644 --- a/src/hooks/__tests__/use-map-signalr-updates.test.ts +++ b/src/hooks/__tests__/use-map-signalr-updates.test.ts @@ -28,6 +28,10 @@ const mockStoreWithTimestamp = (timestamp: number) => { mockUseSignalRStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(state) : state)); }; +// Refetched pins are passed through the live positions held in the store (read imperatively). +let mockLiveLocations: Record = {}; +(useSignalRStore as unknown as { getState: () => unknown }).getState = () => ({ liveLocations: mockLiveLocations }); + describe('useMapSignalRUpdates', () => { beforeEach(() => { jest.useFakeTimers(); @@ -72,6 +76,7 @@ describe('useMapSignalRUpdates', () => { // Reset store state mockStoreWithTimestamp(0); + mockLiveLocations = {}; // Mock successful API response by default mockGetMapDataAndMarkers.mockResolvedValue(mockMapData); }); @@ -531,4 +536,60 @@ describe('useMapSignalRUpdates', () => { }); }); }); + + it('should fetch once, debounced, when a refresh is requested outside the update events', async () => { + mockStoreWithTimestamp(0); + + const { result } = renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate)); + + result.current.requestRefresh(); + result.current.requestRefresh(); + jest.advanceTimersByTime(500); + expect(mockGetMapDataAndMarkers).not.toHaveBeenCalled(); + + jest.runAllTimers(); + + await waitFor(() => { + expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(1); + }); + expect(mockOnMarkersUpdate).toHaveBeenCalledWith(mockMapData.Data.MapMakerInfos); + }); + + it('should re-apply live positions pushed while the refetch was in flight, but not older ones', async () => { + const timestamp = Date.now(); + mockStoreWithTimestamp(timestamp); + + const pins = [ + { ...mockMapData.Data.MapMakerInfos[0], Id: 'u1', Type: 1, Latitude: 40, Longitude: -74 }, + { ...mockMapData.Data.MapMakerInfos[0], Id: 'u2', Type: 1, Latitude: 40, Longitude: -74 }, + ] as MapMakerInfoData[]; + let resolveFetch: (value: GetMapDataAndMarkersResult) => void = () => {}; + mockGetMapDataAndMarkers.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + + renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate)); + jest.runAllTimers(); + + await waitFor(() => { + expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(1); + }); + + // u1's position was received long before the fetch started; u2's arrived while it was in flight. + mockLiveLocations = { + u1: { pinId: 'u1', latitude: 45, longitude: -70, timestamp: null, receivedAt: 1 }, + u2: { pinId: 'u2', latitude: 46, longitude: -71, timestamp: null, receivedAt: Date.now() + 1 }, + }; + resolveFetch({ ...mockMapData, Data: { ...mockMapData.Data, MapMakerInfos: pins } }); + + await waitFor(() => { + expect(mockOnMarkersUpdate).toHaveBeenCalledTimes(1); + }); + const applied = mockOnMarkersUpdate.mock.calls[0][0] as MapMakerInfoData[]; + expect(applied[0]).toBe(pins[0]); + expect(applied[1]).toEqual({ ...pins[1], Latitude: 46, Longitude: -71 }); + }); }); diff --git a/src/hooks/use-map-live-locations.ts b/src/hooks/use-map-live-locations.ts new file mode 100644 index 00000000..40cd459f --- /dev/null +++ b/src/hooks/use-map-live-locations.ts @@ -0,0 +1,133 @@ +import { type Dispatch, type SetStateAction, useEffect, useRef } from 'react'; + +import { applyLiveLocations, diffLiveLocations, type LiveLocations } from '@/lib/live-locations'; +import { logger } from '@/lib/logging'; +import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; +import { useSignalRStore } from '@/stores/signalr/signalr-store'; + +/** Coalescing window for the refetch requested by pushes for pins the map does not have. */ +export const UNKNOWN_PIN_REFRESH_DELAY_MS = 4000; +/** An unknown pin may request a refetch at most this often — the viewer may simply not be allowed to see it. */ +export const UNKNOWN_PIN_REFRESH_COOLDOWN_MS = 5 * 60 * 1000; + +/** + * Re-apply live positions over freshly fetched REST pins. Only positions received at or after the + * moment the fetch STARTED are applied: the snapshot can be older than a push that arrived while it + * was in flight, but anything received before it started is already reflected (or superseded) by it. + */ +export function applyLiveLocationsSince(pins: MapMakerInfoData[], fetchStartedAt: number): MapMakerInfoData[] { + return applyLiveLocations(pins, useSignalRStore.getState().liveLocations, { minReceivedAt: fetchStartedAt }).pins; +} + +/** + * Moves a live map's unit/personnel pins with the geolocation hub's pushes. + * + * Only positions that changed since the last run are applied, so a REST refetch that just replaced + * the pins is not overwritten with older live positions (see `applyLiveLocationsSince` for the + * refetch side). A push for a pin the map does not have asks for one coalesced background refetch via + * `requestRefresh`, and every re-join of the hub after the first asks for a catch-up refetch, since + * positions sent while disconnected were missed. + * + * The store is read through a subscription rather than a selector: pushes arrive per entity per + * location cycle, and the map screen should only re-render when a pin actually moves. + */ +export function useMapLiveLocations(pins: MapMakerInfoData[], setPins: Dispatch>, requestRefresh?: () => void): void { + const pinsRef = useRef(pins); + pinsRef.current = pins; + const requestRefreshRef = useRef(requestRefresh); + requestRefreshRef.current = requestRefresh; + + // Positions already held when the map mounts predate its initial fetch, which covers them. + const appliedRef = useRef(null); + if (appliedRef.current === null) { + appliedRef.current = useSignalRStore.getState().liveLocations; + } + const lastJoinCountRef = useRef(null); + if (lastJoinCountRef.current === null) { + lastJoinCountRef.current = useSignalRStore.getState().geolocationJoinCount; + } + + const unknownRequestedAtRef = useRef>(new Map()); + const unknownRefreshTimerRef = useRef | null>(null); + + useEffect(() => { + const scheduleUnknownPinRefresh = (unknownPinIds: string[]) => { + const now = Date.now(); + const requestedAt = unknownRequestedAtRef.current; + const eligible = unknownPinIds.filter((pinId) => { + const last = requestedAt.get(pinId); + return last === undefined || now - last >= UNKNOWN_PIN_REFRESH_COOLDOWN_MS; + }); + if (eligible.length === 0) { + return; + } + eligible.forEach((pinId) => requestedAt.set(pinId, now)); + + // One refetch for everything that turns up inside the window. + if (unknownRefreshTimerRef.current) { + return; + } + logger.debug({ + message: 'Live location received for pins missing from the map, scheduling a refetch', + context: { unknownPinCount: eligible.length }, + }); + unknownRefreshTimerRef.current = setTimeout(() => { + unknownRefreshTimerRef.current = null; + requestRefreshRef.current?.(); + }, UNKNOWN_PIN_REFRESH_DELAY_MS); + }; + + const applyChangedLocations = (liveLocations: LiveLocations) => { + const changed = diffLiveLocations(appliedRef.current ?? {}, liveLocations); + appliedRef.current = liveLocations; + if (Object.keys(changed).length === 0) { + return; + } + + // Returns the same array when nothing moved, so React bails out without re-rendering. + setPins((current) => applyLiveLocations(current, changed).pins); + + // Until the first fetch lands every push looks unknown; that fetch will bring the pins anyway. + const currentPins = pinsRef.current; + if (!requestRefreshRef.current || currentPins.length === 0) { + return; + } + const { unknownPinIds } = applyLiveLocations(currentPins, changed); + if (unknownPinIds.length > 0) { + scheduleUnknownPinRefresh(unknownPinIds); + } + }; + + const handleJoinCount = (geolocationJoinCount: number) => { + const previous = lastJoinCountRef.current ?? 0; + lastJoinCountRef.current = geolocationJoinCount; + // The first join is covered by the map's own initial fetch; a later one follows a gap in which + // positions were pushed to nobody. + if (geolocationJoinCount > previous && previous > 0) { + requestRefreshRef.current?.(); + } + }; + + // Catch anything that changed between the first render and this subscription. + const current = useSignalRStore.getState(); + applyChangedLocations(current.liveLocations); + handleJoinCount(current.geolocationJoinCount); + + const unsubscribe = useSignalRStore.subscribe((state) => { + if (state.liveLocations !== appliedRef.current) { + applyChangedLocations(state.liveLocations); + } + if (state.geolocationJoinCount !== lastJoinCountRef.current) { + handleJoinCount(state.geolocationJoinCount); + } + }); + + return () => { + unsubscribe(); + if (unknownRefreshTimerRef.current) { + clearTimeout(unknownRefreshTimerRef.current); + unknownRefreshTimerRef.current = null; + } + }; + }, [setPins]); +} diff --git a/src/hooks/use-map-signalr-updates.ts b/src/hooks/use-map-signalr-updates.ts index d241e6b0..03fc049c 100644 --- a/src/hooks/use-map-signalr-updates.ts +++ b/src/hooks/use-map-signalr-updates.ts @@ -5,6 +5,8 @@ import { logger } from '@/lib/logging'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { type UpdateHubEvent, useSignalRStore } from '@/stores/signalr/signalr-store'; +import { applyLiveLocationsSince } from './use-map-live-locations'; + // Debounce delay in milliseconds to prevent rapid consecutive API calls const DEBOUNCE_DELAY = 1000; @@ -56,6 +58,8 @@ export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData context: { timestamp: timestampToProcess }, }); + // Live positions pushed while this request is in flight can be newer than its snapshot. + const fetchStartedAt = Date.now(); const mapDataAndMarkers = await getMapDataAndMarkers(abortController.current.signal); // Check if request was aborted @@ -76,7 +80,7 @@ export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData }, }); - onMarkersUpdate(mapDataAndMarkers.Data.MapMakerInfos); + onMarkersUpdate(applyLiveLocationsSince(mapDataAndMarkers.Data.MapMakerInfos, fetchStartedAt)); } // Update the last processed timestamp after successful API call @@ -171,4 +175,20 @@ export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData } }; }, []); + + /** + * Ask for a background refetch outside the update-hub events (live-location catch-up, pins missing + * from the map). Shares the event debounce, so it coalesces with any event-driven refetch. + */ + const requestRefresh = useCallback(() => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + debounceTimer.current = setTimeout(() => { + debounceTimer.current = null; + fetchAndUpdateMarkers(Date.now()); + }, DEBOUNCE_DELAY); + }, [fetchAndUpdateMarkers]); + + return { requestRefresh }; }; diff --git a/src/lib/__tests__/live-locations.test.ts b/src/lib/__tests__/live-locations.test.ts new file mode 100644 index 00000000..0d6cf844 --- /dev/null +++ b/src/lib/__tests__/live-locations.test.ts @@ -0,0 +1,245 @@ +import { applyLiveLocations, diffLiveLocations, type LiveLocation, type LiveLocations, mergeLiveLocation, parseLiveLocationPayload } from '@/lib/live-locations'; +import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; + +const RECEIVED_AT = 1_700_000_000_000; + +const makePin = (overrides: Partial): MapMakerInfoData => ({ + Id: 'u1', + Longitude: -74.0, + Latitude: 40.7, + Title: 'Pin', + zIndex: 0, + ImagePath: 'engine_available', + InfoWindowContent: '', + Color: '', + Type: 1, + Marker: '', + PoiImage: '', + ...overrides, +}); + +const makeLocation = (overrides: Partial): LiveLocation => ({ + pinId: 'u1', + latitude: 41, + longitude: -73, + timestamp: null, + receivedAt: RECEIVED_AT, + ...overrides, +}); + +describe('parseLiveLocationPayload', () => { + it('parses a camelCase unit push into a u-prefixed pin id', () => { + const result = parseLiveLocationPayload('unit', { departmentId: 1, unitId: '12', latitude: 39.5, longitude: -119.8, recordId: 'r1', timestamp: '2026-09-25T14:03:11.123Z' }, RECEIVED_AT); + + expect(result).toEqual({ pinId: 'u12', latitude: 39.5, longitude: -119.8, timestamp: Date.UTC(2026, 8, 25, 14, 3, 11, 123), receivedAt: RECEIVED_AT }); + }); + + it('parses a personnel push and lower-cases the user id', () => { + const result = parseLiveLocationPayload('personnel', { userId: 'AB12CD34-0000-4000-8000-ABCDEF123456', latitude: 1.5, longitude: 2.5 }, RECEIVED_AT); + + expect(result?.pinId).toBe('pab12cd34-0000-4000-8000-abcdef123456'); + }); + + it('accepts PascalCase field names', () => { + const result = parseLiveLocationPayload('unit', { UnitId: '7', Latitude: 10, Longitude: 20, Timestamp: '2026-09-25T14:00:00Z' }, RECEIVED_AT); + + expect(result).toEqual({ pinId: 'u7', latitude: 10, longitude: 20, timestamp: Date.UTC(2026, 8, 25, 14, 0, 0), receivedAt: RECEIVED_AT }); + }); + + it('accepts the payload as a JSON string', () => { + const result = parseLiveLocationPayload('personnel', JSON.stringify({ userId: 'abc', latitude: 5, longitude: 6 }), RECEIVED_AT); + + expect(result).toEqual({ pinId: 'pabc', latitude: 5, longitude: 6, timestamp: null, receivedAt: RECEIVED_AT }); + }); + + it('accepts a numeric unit id', () => { + expect(parseLiveLocationPayload('unit', { unitId: 42, latitude: 5, longitude: 6 }, RECEIVED_AT)?.pinId).toBe('u42'); + }); + + it.each([ + ['a missing', undefined], + ['a null', null], + ['an empty', ''], + ['an unparseable', 'not a date'], + ])('treats %s timestamp as unknown', (_label, timestamp) => { + expect(parseLiveLocationPayload('unit', { unitId: '1', latitude: 5, longitude: 6, timestamp }, RECEIVED_AT)?.timestamp).toBeNull(); + }); + + it('reads a timestamp without a zone designator as UTC', () => { + expect(parseLiveLocationPayload('unit', { unitId: '1', latitude: 5, longitude: 6, timestamp: '2026-09-25T14:03:11' }, RECEIVED_AT)?.timestamp).toBe(Date.UTC(2026, 8, 25, 14, 3, 11)); + }); + + it.each([ + ['latitude above 90', { latitude: 90.5, longitude: 0.5 }], + ['latitude below -90', { latitude: -91, longitude: 0.5 }], + ['longitude above 180', { latitude: 1, longitude: 180.1 }], + ['longitude below -180', { latitude: 1, longitude: -181 }], + ['non-finite latitude', { latitude: Number.NaN, longitude: 1 }], + ['infinite longitude', { latitude: 1, longitude: Number.POSITIVE_INFINITY }], + ['non-numeric coordinates', { latitude: 'north', longitude: 'west' }], + ['missing coordinates', {}], + ['0,0', { latitude: 0, longitude: 0 }], + ])('rejects %s', (_label, coords) => { + expect(parseLiveLocationPayload('unit', { unitId: '1', ...coords }, RECEIVED_AT)).toBeNull(); + }); + + it.each([ + ['null', null], + ['a number', 12], + ['an array', [1, 2]], + ['malformed JSON', '{not json'], + ])('rejects a payload that is %s', (_label, payload) => { + expect(parseLiveLocationPayload('unit', payload, RECEIVED_AT)).toBeNull(); + }); + + it('rejects a push without an entity id', () => { + expect(parseLiveLocationPayload('unit', { unitId: ' ', latitude: 1, longitude: 2 }, RECEIVED_AT)).toBeNull(); + expect(parseLiveLocationPayload('personnel', { unitId: '12', latitude: 1, longitude: 2 }, RECEIVED_AT)).toBeNull(); + }); +}); + +describe('mergeLiveLocation', () => { + it('adds a position for a new pin without mutating the input', () => { + const current: LiveLocations = {}; + const update = makeLocation({ pinId: 'u1' }); + + const next = mergeLiveLocation(current, update); + + expect(next).toEqual({ u1: update }); + expect(current).toEqual({}); + }); + + it('keeps positions per pin so a burst of pushes loses none of them', () => { + let state: LiveLocations = {}; + state = mergeLiveLocation(state, makeLocation({ pinId: 'u1' })); + state = mergeLiveLocation(state, makeLocation({ pinId: 'u2' })); + state = mergeLiveLocation(state, makeLocation({ pinId: 'pabc' })); + + expect(Object.keys(state).sort()).toEqual(['pabc', 'u1', 'u2']); + }); + + it('ignores an update whose fix is older than the one already held', () => { + const current: LiveLocations = { u1: makeLocation({ timestamp: 2000, latitude: 41 }) }; + + const next = mergeLiveLocation(current, makeLocation({ timestamp: 1000, latitude: 42 })); + + expect(next).toBe(current); + }); + + it('applies a newer fix', () => { + const current: LiveLocations = { u1: makeLocation({ timestamp: 1000, latitude: 41 }) }; + const update = makeLocation({ timestamp: 2000, latitude: 42 }); + + expect(mergeLiveLocation(current, update).u1).toBe(update); + }); + + it('always applies an update with an unknown timestamp', () => { + const current: LiveLocations = { u1: makeLocation({ timestamp: 5000, latitude: 41 }) }; + const update = makeLocation({ timestamp: null, latitude: 43 }); + + expect(mergeLiveLocation(current, update).u1).toBe(update); + }); + + it('applies a timestamped update over one with an unknown timestamp', () => { + const current: LiveLocations = { u1: makeLocation({ timestamp: null, latitude: 41 }) }; + const update = makeLocation({ timestamp: 1, latitude: 43 }); + + expect(mergeLiveLocation(current, update).u1).toBe(update); + }); +}); + +describe('diffLiveLocations', () => { + it('returns only new or replaced entries', () => { + const kept = makeLocation({ pinId: 'u1' }); + const previous: LiveLocations = { u1: kept, u2: makeLocation({ pinId: 'u2' }) }; + const replaced = makeLocation({ pinId: 'u2', latitude: 10 }); + const added = makeLocation({ pinId: 'u3' }); + + expect(diffLiveLocations(previous, { u1: kept, u2: replaced, u3: added })).toEqual({ u2: replaced, u3: added }); + }); + + it('returns an empty object for the same map', () => { + const same: LiveLocations = { u1: makeLocation({}) }; + expect(diffLiveLocations(same, same)).toEqual({}); + }); +}); + +describe('applyLiveLocations', () => { + const unitPin = makePin({ Id: 'u1', Type: 1, Latitude: 40, Longitude: -74 }); + const personPin = makePin({ Id: 'pAB12-CD', Type: 3, Latitude: 30, Longitude: -80 }); + const callPin = makePin({ Id: 'c9', Type: 0, Latitude: 20, Longitude: -90 }); + const stationPin = makePin({ Id: 's4', Type: 2, Latitude: 10, Longitude: -100 }); + const pins = [callPin, unitPin, personPin, stationPin]; + + it('returns the same array when there are no live locations', () => { + const result = applyLiveLocations(pins, {}); + expect(result.pins).toBe(pins); + expect(result.unknownPinIds).toEqual([]); + }); + + it('returns the same array when the live position equals the pin position', () => { + const result = applyLiveLocations(pins, { u1: makeLocation({ pinId: 'u1', latitude: 40, longitude: -74 }) }); + expect(result.pins).toBe(pins); + expect(result.unknownPinIds).toEqual([]); + }); + + it('moves a pin immutably, replacing only the moved pin object', () => { + const result = applyLiveLocations(pins, { u1: makeLocation({ pinId: 'u1', latitude: 41.5, longitude: -73.5 }) }); + + expect(result.pins).not.toBe(pins); + expect(result.pins[1]).toEqual({ ...unitPin, Latitude: 41.5, Longitude: -73.5 }); + expect(result.pins[1]).not.toBe(unitPin); + // Untouched pins keep their identity so their memoized markers do not re-render. + expect(result.pins[0]).toBe(callPin); + expect(result.pins[2]).toBe(personPin); + expect(result.pins[3]).toBe(stationPin); + // The input is never mutated. + expect(unitPin.Latitude).toBe(40); + expect(pins[1]).toBe(unitPin); + }); + + it('matches personnel pins case-insensitively', () => { + const result = applyLiveLocations(pins, { 'pab12-cd': makeLocation({ pinId: 'pab12-cd', latitude: 31, longitude: -81 }) }); + + expect(result.pins[2]).toEqual({ ...personPin, Latitude: 31, Longitude: -81 }); + expect(result.unknownPinIds).toEqual([]); + }); + + it('never adds pins and reports positions without a pin as unknown', () => { + const result = applyLiveLocations(pins, { u99: makeLocation({ pinId: 'u99' }), u1: makeLocation({ pinId: 'u1', latitude: 42 }) }); + + expect(result.pins).toHaveLength(pins.length); + expect(result.pins.some((pin) => pin.Id === 'u99')).toBe(false); + expect(result.unknownPinIds).toEqual(['u99']); + }); + + it('only moves unit and personnel pins', () => { + // A call or station pin whose id happens to look like a unit/personnel id stays put. + const lookalikes = [makePin({ Id: 'u1', Type: 0, Latitude: 1, Longitude: 1 }), makePin({ Id: 'pabc', Type: 2, Latitude: 1, Longitude: 1 })]; + + const result = applyLiveLocations(lookalikes, { u1: makeLocation({ pinId: 'u1' }), pabc: makeLocation({ pinId: 'pabc' }) }); + + expect(result.pins).toBe(lookalikes); + expect(result.unknownPinIds.sort()).toEqual(['pabc', 'u1']); + }); + + it('only applies positions received at or after minReceivedAt', () => { + const live: LiveLocations = { + u1: makeLocation({ pinId: 'u1', latitude: 45, receivedAt: 1000 }), + 'pab12-cd': makeLocation({ pinId: 'pab12-cd', latitude: 35, receivedAt: 2000 }), + }; + + const result = applyLiveLocations(pins, live, { minReceivedAt: 2000 }); + + expect(result.pins[1]).toBe(unitPin); + expect(result.pins[2].Latitude).toBe(35); + }); + + it('keeps the newer position when an older fix arrives later', () => { + let live: LiveLocations = {}; + live = mergeLiveLocation(live, makeLocation({ pinId: 'u1', latitude: 42, timestamp: 2000 })); + live = mergeLiveLocation(live, makeLocation({ pinId: 'u1', latitude: 41, timestamp: 1000 })); + + expect(applyLiveLocations(pins, live).pins[1].Latitude).toBe(42); + }); +}); diff --git a/src/lib/__tests__/map-pin-ids.test.ts b/src/lib/__tests__/map-pin-ids.test.ts new file mode 100644 index 00000000..c65d1462 --- /dev/null +++ b/src/lib/__tests__/map-pin-ids.test.ts @@ -0,0 +1,69 @@ +import { getPinEntityId, isCallPin, isPinForCall, MapPinType } from '../map-pin-ids'; + +const personId = '4f1c2b7e-0000-4000-8000-00000000abcd'; + +describe('map pin ids', () => { + describe('getPinEntityId', () => { + it.each([ + ['c123', MapPinType.Call, '123'], + ['u12', MapPinType.Unit, '12'], + ['s5', MapPinType.Station, '5'], + [`p${personId}`, MapPinType.Personnel, personId], + ['poi9', MapPinType.Poi, '9'], + ['hydrant-44', MapPinType.Hydrant, '44'], + ])('strips the type prefix from %s', (id, type, expected) => { + expect(getPinEntityId({ Id: id, Type: type })).toBe(expected); + }); + + it('matches the prefix case-insensitively', () => { + expect(getPinEntityId({ Id: 'C123', Type: MapPinType.Call })).toBe('123'); + }); + + it.each([ + ['123', MapPinType.Call], + ['12', MapPinType.Unit], + ['9', MapPinType.Poi], + [personId, MapPinType.Personnel], + ])('leaves the legacy unprefixed id %s unchanged', (id, type) => { + expect(getPinEntityId({ Id: id, Type: type })).toBe(id); + }); + + it('does not strip a prefix from a numeric type when the rest is not a number', () => { + expect(getPinEntityId({ Id: 'cat', Type: MapPinType.Call })).toBe('cat'); + expect(getPinEntityId({ Id: 'poison', Type: MapPinType.Poi })).toBe('poison'); + }); + + it('does not strip a prefix that belongs to another pin type', () => { + expect(getPinEntityId({ Id: 'u12', Type: MapPinType.Call })).toBe('u12'); + }); + + it('leaves ids of unknown pin types and empty ids alone', () => { + expect(getPinEntityId({ Id: 'x7', Type: 99 })).toBe('x7'); + expect(getPinEntityId({ Id: '', Type: MapPinType.Call })).toBe(''); + expect(getPinEntityId({ Id: 'c', Type: MapPinType.Call })).toBe('c'); + expect(getPinEntityId({ Id: undefined as unknown as string, Type: MapPinType.Call })).toBe(''); + }); + }); + + describe('isCallPin', () => { + it('is true only for call pins', () => { + expect(isCallPin({ Type: MapPinType.Call })).toBe(true); + expect(isCallPin({ Type: MapPinType.Unit })).toBe(false); + expect(isCallPin({ Type: MapPinType.Personnel })).toBe(false); + }); + }); + + describe('isPinForCall', () => { + it('matches a prefixed or legacy call pin against the bare call id', () => { + expect(isPinForCall({ Id: 'c123', Type: MapPinType.Call }, '123')).toBe(true); + expect(isPinForCall({ Id: '123', Type: MapPinType.Call }, 123)).toBe(true); + }); + + it('does not match other calls, other pin types or a missing call id', () => { + expect(isPinForCall({ Id: 'c123', Type: MapPinType.Call }, '124')).toBe(false); + expect(isPinForCall({ Id: 'u123', Type: MapPinType.Unit }, '123')).toBe(false); + expect(isPinForCall({ Id: 'c123', Type: MapPinType.Call }, null)).toBe(false); + expect(isPinForCall({ Id: 'c123', Type: MapPinType.Call }, '')).toBe(false); + }); + }); +}); diff --git a/src/lib/live-locations.ts b/src/lib/live-locations.ts new file mode 100644 index 00000000..850601c9 --- /dev/null +++ b/src/lib/live-locations.ts @@ -0,0 +1,224 @@ +import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; + +/** + * Realtime unit/personnel positions pushed by the geolocation hub, and the pure helpers that move + * live-map pins with them. + * + * The hub broadcasts every position in the department to the whole department group. Unlike the REST + * map endpoint it applies neither the per-viewer visibility matrix nor the location TTLs, so a push may + * only MOVE a pin the REST map data already contains — it must never create one. + */ + +/** `MapMakerInfoData.Type` for a unit pin (`u{UnitId}`). */ +export const UNIT_PIN_TYPE = 1; +/** `MapMakerInfoData.Type` for a personnel pin (`p{UserId}`). */ +export const PERSONNEL_PIN_TYPE = 3; + +export type LiveLocationKind = 'unit' | 'personnel'; + +export interface LiveLocation { + /** The map pin this position belongs to: `u{UnitId}` or `p{userId}`, always lower-case. */ + pinId: string; + latitude: number; + longitude: number; + /** UTC time of the GPS fix in epoch ms, or null when the server did not send a usable one. */ + timestamp: number | null; + /** Epoch ms at which this client received the push. */ + receivedAt: number; +} + +/** Latest live position per pin id. */ +export type LiveLocations = Record; + +export interface ApplyLiveLocationsOptions { + /** Only apply positions received at or after this epoch ms — e.g. when a REST refetch started. */ + minReceivedAt?: number; +} + +export interface ApplyLiveLocationsResult { + /** The same array when nothing moved; otherwise a copy with new objects for the moved pins only. */ + pins: MapMakerInfoData[]; + /** Pin ids that had a live position but no unit/personnel pin in `pins`. */ + unknownPinIds: string[]; +} + +// An ISO-8601 time that already says which zone it is in. +const HAS_ZONE_DESIGNATOR = /(?:z|[+-]\d{2}:?\d{2})$/i; + +function toPayloadRecord(payload: unknown): Record | null { + let value = payload; + if (typeof value === 'string') { + try { + value = JSON.parse(value); + } catch { + return null; + } + } + return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; +} + +/** The server's JSON protocol sends camelCase; older producers and hand-rolled payloads use PascalCase. */ +function readField(record: Record, camelName: string): unknown { + const camelValue = record[camelName]; + if (camelValue !== undefined && camelValue !== null) { + return camelValue; + } + return record[camelName.charAt(0).toUpperCase() + camelName.slice(1)]; +} + +function toCoordinate(value: unknown): number { + if (typeof value === 'number') return value; + if (typeof value === 'string' && value.trim().length > 0) return Number(value); + return Number.NaN; +} + +function toIdentifier(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed.toLowerCase() : null; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + return null; +} + +/** + * Epoch ms of the fix, or null for "unknown". The field is new — older servers omit it — and a value + * that cannot be read must not block the update, so anything unusable degrades to unknown. + */ +function toFixTimestamp(value: unknown): number | null { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + if (trimmed.length === 0) { + return null; + } + // The contract is UTC. A .NET DateTime with an unspecified kind serializes without a zone, which + // JavaScript would otherwise read as device-local time. + const parsed = Date.parse(HAS_ZONE_DESIGNATOR.test(trimmed) ? trimmed : `${trimmed}Z`); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * Parse an `onUnitLocationUpdated` / `onPersonnelLocationUpdated` push into a live location, or null + * when the payload is unusable (no id, non-finite or out-of-range coordinates, or 0,0). + */ +export function parseLiveLocationPayload(kind: LiveLocationKind, payload: unknown, receivedAt: number = Date.now()): LiveLocation | null { + const record = toPayloadRecord(payload); + if (!record) { + return null; + } + + const entityId = toIdentifier(readField(record, kind === 'unit' ? 'unitId' : 'userId')); + if (!entityId) { + return null; + } + + const latitude = toCoordinate(readField(record, 'latitude')); + const longitude = toCoordinate(readField(record, 'longitude')); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || Math.abs(latitude) > 90 || Math.abs(longitude) > 180) { + return null; + } + // 0,0 is what an unset position serializes as, not a real fix. + if (latitude === 0 && longitude === 0) { + return null; + } + + return { + pinId: `${kind === 'unit' ? 'u' : 'p'}${entityId}`, + latitude, + longitude, + timestamp: toFixTimestamp(readField(record, 'timestamp')), + receivedAt, + }; +} + +/** + * Record an update for its pin. Trackers replay buffered fixes and queue consumers can reorder, so an + * update whose fix is OLDER than the one already held for that pin is dropped (the same object is + * returned). An update with an unknown timestamp always applies. + */ +export function mergeLiveLocation(current: LiveLocations, update: LiveLocation): LiveLocations { + const existing = current[update.pinId]; + if (existing && existing.timestamp !== null && update.timestamp !== null && update.timestamp < existing.timestamp) { + return current; + } + return { ...current, [update.pinId]: update }; +} + +/** The entries of `next` that are new or replaced relative to `previous` (by reference). */ +export function diffLiveLocations(previous: LiveLocations, next: LiveLocations): LiveLocations { + if (previous === next) { + return {}; + } + const changed: LiveLocations = {}; + Object.keys(next).forEach((pinId) => { + const location = next[pinId]; + if (location && previous[pinId] !== location) { + changed[pinId] = location; + } + }); + return changed; +} + +/** + * Move unit (Type 1) and personnel (Type 3) pins to their live positions. + * + * Pins are `React.memo`'d, so this never mutates: it returns the SAME array when nothing moved, and + * otherwise a copy in which only the moved pins are new objects. Ids match case-insensitively (the + * REST user id and the pushed one can differ in casing). A live position without a matching pin is + * reported in `unknownPinIds` and never turned into a pin. + */ +export function applyLiveLocations(pins: MapMakerInfoData[], liveLocations: LiveLocations, options: ApplyLiveLocationsOptions = {}): ApplyLiveLocationsResult { + const { minReceivedAt } = options; + + const pending = new Map(); + Object.keys(liveLocations).forEach((key) => { + const location = liveLocations[key]; + if (!location || (minReceivedAt !== undefined && location.receivedAt < minReceivedAt)) { + return; + } + pending.set(location.pinId.toLowerCase(), location); + }); + + if (pending.size === 0) { + return { pins, unknownPinIds: [] }; + } + + let next: MapMakerInfoData[] | null = null; + const matched = new Set(); + + for (let index = 0; index < pins.length; index += 1) { + const pin = pins[index]; + if (!pin || (pin.Type !== UNIT_PIN_TYPE && pin.Type !== PERSONNEL_PIN_TYPE) || typeof pin.Id !== 'string') { + continue; + } + const pinId = pin.Id.toLowerCase(); + const location = pending.get(pinId); + if (!location) { + continue; + } + matched.add(pinId); + if (pin.Latitude === location.latitude && pin.Longitude === location.longitude) { + continue; + } + if (!next) { + next = pins.slice(); + } + next[index] = { ...pin, Latitude: location.latitude, Longitude: location.longitude }; + } + + const unknownPinIds: string[] = []; + pending.forEach((_location, pinId) => { + if (!matched.has(pinId)) { + unknownPinIds.push(pinId); + } + }); + + return { pins: next ?? pins, unknownPinIds }; +} diff --git a/src/lib/map-pin-ids.ts b/src/lib/map-pin-ids.ts new file mode 100644 index 00000000..4fff267d --- /dev/null +++ b/src/lib/map-pin-ids.ts @@ -0,0 +1,58 @@ +import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; + +/** + * Pin types returned by the Core map API (`/Mapping/GetMapDataAndMarkers`). + */ +export const MapPinType = { + Call: 0, + Unit: 1, + Station: 2, + Personnel: 3, + Poi: 4, + Hydrant: 5, +} as const; + +// The Core map API prefixes each pin id with its type so ids are unique across types +// (`c123` is call 123, `p` a person). Servers from before that change send the bare id. +const PIN_ID_PREFIXES: Record = { + [MapPinType.Call]: 'c', + [MapPinType.Unit]: 'u', + [MapPinType.Station]: 's', + [MapPinType.Personnel]: 'p', + [MapPinType.Poi]: 'poi', + [MapPinType.Hydrant]: 'hydrant-', +}; + +const NUMERIC_ID_TYPES = new Set([MapPinType.Call, MapPinType.Unit, MapPinType.Station, MapPinType.Poi]); + +type PinIdentity = Pick; + +/** + * The id of the call, unit, station, person, POI or hydrant behind a map pin, i.e. the pin id without + * its type prefix, for routes and stores that take the entity id. A legacy unprefixed id, or an id of + * any other pin type, is returned unchanged. + */ +export const getPinEntityId = (pin: PinIdentity): string => { + const id = pin.Id == null ? '' : String(pin.Id).trim(); + const prefix = PIN_ID_PREFIXES[pin.Type]; + + if (!prefix || id.length <= prefix.length || id.slice(0, prefix.length).toLowerCase() !== prefix) { + return id; + } + + const entityId = id.slice(prefix.length); + + // Call, unit, station and POI ids are numbers; anything else is not a prefixed id, so leave it alone. + if (NUMERIC_ID_TYPES.has(pin.Type) && !/^\d+$/.test(entityId)) { + return id; + } + + return entityId; +}; + +export const isCallPin = (pin: Pick): boolean => pin.Type === MapPinType.Call; + +/** + * Whether the pin is the given call, e.g. the active call (stores hold the bare call id). + */ +export const isPinForCall = (pin: PinIdentity, callId: string | number | null | undefined): boolean => callId != null && String(callId) !== '' && isCallPin(pin) && getPinEntityId(pin) === String(callId); diff --git a/src/lib/storage/__tests__/app.test.ts b/src/lib/storage/__tests__/app.test.ts new file mode 100644 index 00000000..9105f17f --- /dev/null +++ b/src/lib/storage/__tests__/app.test.ts @@ -0,0 +1,51 @@ +jest.mock('@env', () => ({ Env: { BASE_API_URL: 'https://api.example.com', API_VERSION: 'v4' } })); + +const mockStore = new Map(); +jest.mock('@/lib/storage', () => ({ + getItem: jest.fn((key: string) => (mockStore.has(key) ? mockStore.get(key) : null)), + setItem: jest.fn(async (key: string, value: unknown) => { + mockStore.set(key, value); + }), + removeItem: jest.fn(async (key: string) => { + mockStore.delete(key); + }), +})); + +import { getActiveCallId, getActiveUnitId, removeActiveCallId, removeActiveUnitId, setActiveCallId, setActiveUnitId } from '../app'; + +// The core store's init() restores the crew's unit and call from these on every launch. +describe('active unit and call persistence', () => { + beforeEach(() => { + mockStore.clear(); + }); + + it('returns the unit id that was saved', async () => { + await setActiveUnitId('unit-42'); + + expect(getActiveUnitId()).toBe('unit-42'); + }); + + it('returns null when no unit has been saved or it was removed', async () => { + expect(getActiveUnitId()).toBeNull(); + + await setActiveUnitId('unit-42'); + await removeActiveUnitId(); + + expect(getActiveUnitId()).toBeNull(); + }); + + it('returns the call id that was saved', async () => { + await setActiveCallId('call-7'); + + expect(getActiveCallId()).toBe('call-7'); + }); + + it('returns null when no call has been saved or it was removed', async () => { + expect(getActiveCallId()).toBeNull(); + + await setActiveCallId('call-7'); + await removeActiveCallId(); + + expect(getActiveCallId()).toBeNull(); + }); +}); diff --git a/src/lib/storage/app.tsx b/src/lib/storage/app.tsx index 30c30428..2522aab3 100644 --- a/src/lib/storage/app.tsx +++ b/src/lib/storage/app.tsx @@ -2,16 +2,16 @@ import { Env } from '@env'; import { getItem, removeItem, setItem } from '@/lib/storage'; -const BASE_URL = 'baseUrl'; +export const BASE_API_URL_STORAGE_KEY = 'baseUrl'; const ACTIVE_UNIT_ID = 'activeUnitId'; const ACTIVE_CALL_ID = 'activeCallId'; const DEVICE_UUID = 'unitDeviceUuid'; -export const removeBaseApiUrl = () => removeItem(BASE_URL); -export const setBaseApiUrl = (value: string) => setItem(BASE_URL, value); +export const removeBaseApiUrl = () => removeItem(BASE_API_URL_STORAGE_KEY); +export const setBaseApiUrl = (value: string) => setItem(BASE_API_URL_STORAGE_KEY, value); export const getBaseApiUrl = () => { - const baseUrl = getItem(BASE_URL); + const baseUrl = getItem(BASE_API_URL_STORAGE_KEY); if (!baseUrl) { return `${Env.BASE_API_URL}/api/${Env.API_VERSION}`; } @@ -21,24 +21,14 @@ export const getBaseApiUrl = () => { export const removeActiveUnitId = () => removeItem(ACTIVE_UNIT_ID); export const setActiveUnitId = (value: string) => setItem(ACTIVE_UNIT_ID, value); -export const getActiveUnitId = () => { - const activeUnitId = getItem(ACTIVE_UNIT_ID); - if (!activeUnitId) { - return activeUnitId; - } - return ''; -}; +/** The unit the crew last selected, restored on launch by the core store's init. */ +export const getActiveUnitId = (): string | null => getItem(ACTIVE_UNIT_ID) || null; export const removeActiveCallId = () => removeItem(ACTIVE_CALL_ID); export const setActiveCallId = (value: string) => setItem(ACTIVE_CALL_ID, value); -export const getActiveCallId = () => { - const activeCallId = getItem(ACTIVE_CALL_ID); - if (!activeCallId) { - return activeCallId; - } - return ''; -}; +/** The call the crew last made active, restored on launch by the core store's init. */ +export const getActiveCallId = (): string | null => getItem(ACTIVE_CALL_ID) || null; export const removeDeviceUuid = () => removeItem(DEVICE_UUID); export const setDeviceUuid = (value: string) => setItem(DEVICE_UUID, value); diff --git a/src/models/v4/contacts/contactResultData.ts b/src/models/v4/contacts/contactResultData.ts index 1fd19d88..3a1451cc 100644 --- a/src/models/v4/contacts/contactResultData.ts +++ b/src/models/v4/contacts/contactResultData.ts @@ -91,7 +91,10 @@ export interface ContactAddressData { export interface ContactCustomFieldData { UdfFieldId: string; Label: string; + /** The stored value; for option fields this is the option key. */ Value: string; + /** What to show: option keys resolved to labels, booleans as Yes/No. Older servers do not send it. */ + DisplayValue?: string | null; FieldDataType: number; GroupName?: string | null; SortOrder: number; diff --git a/src/services/__tests__/app-reset.service.test.ts b/src/services/__tests__/app-reset.service.test.ts index 24c3a745..10e2994f 100644 --- a/src/services/__tests__/app-reset.service.test.ts +++ b/src/services/__tests__/app-reset.service.test.ts @@ -11,13 +11,14 @@ jest.mock('@/lib/logging', () => ({ // Mock storage jest.mock('@/lib/storage', () => ({ storage: { - getAllKeys: jest.fn(() => ['key1', 'IS_FIRST_TIME', 'key2']), + getAllKeys: jest.fn(() => ['key1', 'IS_FIRST_TIME', 'baseUrl', 'key2']), delete: jest.fn(), }, })); // Mock storage/app functions jest.mock('@/lib/storage/app', () => ({ + BASE_API_URL_STORAGE_KEY: 'baseUrl', removeActiveUnitId: jest.fn(), removeActiveCallId: jest.fn(), removeDeviceUuid: jest.fn(), @@ -589,6 +590,12 @@ describe('app-reset.service', () => { expect(mockStorage.delete).toHaveBeenCalledWith('key2'); expect(mockStorage.delete).not.toHaveBeenCalledWith('IS_FIRST_TIME'); }); + + it('should preserve the selected server URL', () => { + clearPersistedStorage(); + + expect(mockStorage.delete).not.toHaveBeenCalledWith('baseUrl'); + }); }); describe('resetAllStores', () => { diff --git a/src/services/__tests__/location-fix.test.ts b/src/services/__tests__/location-fix.test.ts index ce60079a..af8de651 100644 --- a/src/services/__tests__/location-fix.test.ts +++ b/src/services/__tests__/location-fix.test.ts @@ -2,7 +2,7 @@ import * as Location from 'expo-location'; import { translate } from '@/lib/i18n/utils'; -import { acquireLocationFix, getLocationFixErrorMessage } from '../location-fix'; +import { acquireLocationFix, getLocationFixErrorMessage, readRecentLocation } from '../location-fix'; jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, @@ -140,6 +140,50 @@ describe('acquireLocationFix', () => { }); }); +// Used for statuses that do not require GPS, which must never wait on the radio. +describe('readRecentLocation', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockLocation.getForegroundPermissionsAsync.mockResolvedValue(granted); + mockLocation.getLastKnownPositionAsync.mockResolvedValue(position); + }); + + it('returns what the OS already has from the last minute without taking a live fix', async () => { + await expect(readRecentLocation()).resolves.toEqual(position); + + expect(mockLocation.getLastKnownPositionAsync).toHaveBeenCalledWith({ maxAge: 60 * 1000 }); + expect(mockLocation.getCurrentPositionAsync).not.toHaveBeenCalled(); + expect(mockLocation.hasServicesEnabledAsync).not.toHaveBeenCalled(); + }); + + it('never prompts for permission', async () => { + mockLocation.getForegroundPermissionsAsync.mockResolvedValue(undetermined); + + await expect(readRecentLocation()).resolves.toBeNull(); + + expect(mockLocation.requestForegroundPermissionsAsync).not.toHaveBeenCalled(); + expect(mockLocation.getLastKnownPositionAsync).not.toHaveBeenCalled(); + }); + + it('returns null rather than throwing when the platform call fails', async () => { + mockLocation.getLastKnownPositionAsync.mockRejectedValue(new Error('unavailable')); + + await expect(readRecentLocation()).resolves.toBeNull(); + }); + + it('gives up on a read that never settles rather than holding the submission', async () => { + jest.useFakeTimers(); + mockLocation.getLastKnownPositionAsync.mockReturnValue(new Promise(() => {}) as Promise); + + const pending = readRecentLocation(); + await Promise.resolve(); + jest.advanceTimersByTime(2000); + + await expect(pending).resolves.toBeNull(); + jest.useRealTimers(); + }); +}); + describe('getLocationFixErrorMessage', () => { const mockTranslate = translate as jest.MockedFunction; diff --git a/src/services/__tests__/offline-event-manager.service.test.ts b/src/services/__tests__/offline-event-manager.service.test.ts index dbfe08d1..95c68809 100644 --- a/src/services/__tests__/offline-event-manager.service.test.ts +++ b/src/services/__tests__/offline-event-manager.service.test.ts @@ -575,4 +575,116 @@ describe('OfflineEventManager', () => { expect(mockStoreState.initializeNetworkListener).toHaveBeenCalled(); }); }); + + describe('isDeviceOffline', () => { + it('is true only when there is no network interface', () => { + mockStoreState.isConnected = false; + expect(offlineEventManager.isDeviceOffline()).toBe(true); + + // Reachability reads false while unknown — that alone must not be treated as offline. + mockStoreState.isConnected = true; + mockStoreState.isNetworkReachable = false; + expect(offlineEventManager.isDeviceOffline()).toBe(false); + }); + }); + + // The server takes a unit's most recently inserted state as current, so a newer status must not + // overtake an older one still in the queue — it would be reverted when the older one replays. + describe('delivering queued unit statuses ahead of a new one', () => { + const networkError = () => Object.assign(new Error('Network Error'), { isAxiosError: true, code: 'ERR_NETWORK', request: {} }); + const httpError = (status: number) => Object.assign(new Error(`Request failed with status code ${status}`), { isAxiosError: true, response: { status } }); + + const statusEvent = (id: string, unitId: string, createdAt: number, overrides: Record = {}) => ({ + id, + type: QueuedEventType.UNIT_STATUS, + status: QueuedEventStatus.PENDING, + data: { unitId, statusType: id, timestamp: '2026-09-23T10:00:00.000Z', timestampUtc: 'Wed, 23 Sep 2026 10:00:00 GMT' }, + retryCount: 0, + maxRetries: 3, + createdAt, + ...overrides, + }); + + // A queue that actually records status changes, so what a delivery left behind can be read back. + const useQueue = (events: any[]) => { + mockStoreState.queuedEvents = events; + mockStoreState.getEventById = jest.fn((id: string) => mockStoreState.queuedEvents.find((event: any) => event.id === id)); + mockStoreState.getPendingEvents = jest.fn(() => mockStoreState.queuedEvents.filter((event: any) => event.status === QueuedEventStatus.PENDING)); + mockStoreState.updateEventStatus = jest.fn((id: string, status: QueuedEventStatus, error?: string, options?: { permanent?: boolean }) => { + mockStoreState.queuedEvents = mockStoreState.queuedEvents.map((event: any) => { + if (event.id !== id) { + return event; + } + const retryCount = status === QueuedEventStatus.FAILED ? (options?.permanent ? event.maxRetries : event.retryCount + 1) : event.retryCount; + return { ...event, status, error, retryCount }; + }); + }); + }; + + const statusOf = (id: string) => mockStoreState.queuedEvents.find((event: any) => event.id === id); + + it('reports undelivered statuses per unit, ignoring other units, other event types and permanent failures', () => { + useQueue([ + statusEvent('other-unit', 'unit-2', 1), + statusEvent('parked', 'unit-1', 2, { status: QueuedEventStatus.FAILED, retryCount: 3 }), + { ...statusEvent('location', 'unit-1', 3), type: QueuedEventType.LOCATION_UPDATE }, + ]); + expect(offlineEventManager.hasUndeliveredUnitStatuses('unit-1')).toBe(false); + + useQueue([statusEvent('retrying', 'unit-1', 1, { status: QueuedEventStatus.FAILED, retryCount: 1, nextRetryAt: Date.now() + 60000 })]); + expect(offlineEventManager.hasUndeliveredUnitStatuses('unit-1')).toBe(true); + }); + + it('sends the unit’s queued statuses oldest first and reports it caught up', async () => { + useQueue([statusEvent('second', 'unit-1', 2), statusEvent('first', 'unit-1', 1), statusEvent('other-unit', 'unit-2', 0)]); + mockSaveUnitStatus.mockResolvedValue({} as any); + + await expect(offlineEventManager.deliverQueuedUnitStatuses('unit-1')).resolves.toBe(true); + + expect(mockSaveUnitStatus.mock.calls.map(([input]) => input.Type)).toEqual(['first', 'second']); + expect(statusOf('first').status).toBe(QueuedEventStatus.COMPLETED); + expect(statusOf('second').status).toBe(QueuedEventStatus.COMPLETED); + expect(statusOf('other-unit').status).toBe(QueuedEventStatus.PENDING); + }); + + it('stops at a status it cannot deliver, keeping the order and the event’s retry budget', async () => { + useQueue([statusEvent('first', 'unit-1', 1, { retryCount: 1 }), statusEvent('second', 'unit-1', 2)]); + mockSaveUnitStatus.mockRejectedValueOnce(networkError()); + + await expect(offlineEventManager.deliverQueuedUnitStatuses('unit-1')).resolves.toBe(false); + + expect(mockSaveUnitStatus).toHaveBeenCalledTimes(1); + // Back to pending for the background processor, not a spent retry. + expect(statusOf('first')).toEqual(expect.objectContaining({ status: QueuedEventStatus.PENDING, retryCount: 1 })); + expect(statusOf('second').status).toBe(QueuedEventStatus.PENDING); + }); + + it('is not held up by a status the server permanently rejected', async () => { + useQueue([statusEvent('rejected', 'unit-1', 1)]); + mockSaveUnitStatus.mockRejectedValueOnce(httpError(403)); + + await expect(offlineEventManager.deliverQueuedUnitStatuses('unit-1')).resolves.toBe(true); + + expect(statusOf('rejected')).toEqual(expect.objectContaining({ status: QueuedEventStatus.FAILED, retryCount: 3 })); + }); + + it('waits for a batch the timer already started instead of sending its events twice', async () => { + useQueue([statusEvent('first', 'unit-1', 1)]); + let finishTimerSend: (value: unknown) => void = () => {}; + mockSaveUnitStatus.mockImplementationOnce(() => new Promise((resolve) => (finishTimerSend = resolve)) as any); + + const timerBatch = (offlineEventManager as any).processQueuedEvents(); + const delivery = offlineEventManager.deliverQueuedUnitStatuses('unit-1'); + await Promise.resolve(); + + expect(mockSaveUnitStatus).toHaveBeenCalledTimes(1); + + finishTimerSend({}); + await timerBatch; + + await expect(delivery).resolves.toBe(true); + expect(mockSaveUnitStatus).toHaveBeenCalledTimes(1); + expect(statusOf('first').status).toBe(QueuedEventStatus.COMPLETED); + }); + }); }); diff --git a/src/services/__tests__/signalr.service.test.ts b/src/services/__tests__/signalr.service.test.ts index f3a31970..42883da4 100644 --- a/src/services/__tests__/signalr.service.test.ts +++ b/src/services/__tests__/signalr.service.test.ts @@ -501,6 +501,43 @@ describe('SignalRService', () => { }); }); + describe('message logging', () => { + it('does not log location pushes or any message payload', async () => { + const unitListener = jest.fn(); + signalRService.on('onUnitLocationUpdated', unitListener); + + try { + await signalRService.connectToHubWithEventingUrl({ + name: 'loggingHub', + eventingUrl: 'https://api.example.com/', + hubName: 'eventingHub', + methods: ['onUnitLocationUpdated', 'onPersonnelLocationUpdated', 'callsUpdated'], + }); + + const handlerFor = (method: string) => mockConnection.on.mock.calls.find((call) => call[0] === method)?.[1] as (...args: unknown[]) => void; + const unitPush = { departmentId: 7, unitId: '12', latitude: 47.6062123, longitude: -122.3321456, recordId: 'r1', timestamp: '2026-09-25T14:03:11Z' }; + const personPush = { departmentId: 7, userId: 'user-1', latitude: 47.6062123, longitude: -122.3321456, recordId: 'r2', timestamp: '2026-09-25T14:03:12Z' }; + + handlerFor('onUnitLocationUpdated')(unitPush); + handlerFor('onPersonnelLocationUpdated')(personPush); + handlerFor('callsUpdated')({ body: 'private call notes' }); + + const logged = JSON.stringify([mockLogger.debug, mockLogger.info, mockLogger.warn, mockLogger.error].flatMap((log) => log.mock.calls)); + expect(logged).not.toContain('47.6062123'); + expect(logged).not.toContain('-122.3321456'); + expect(logged).not.toContain('private call notes'); + expect(logged).not.toMatch(/Received on(Unit|Personnel)LocationUpdated/); + expect(mockLogger.debug).toHaveBeenCalledWith({ message: 'Received callsUpdated message from hub: loggingHub', context: { method: 'callsUpdated' } }); + + // Logging less must not deliver less. + expect(unitListener).toHaveBeenCalledWith(unitPush); + } finally { + signalRService.off('onUnitLocationUpdated', unitListener); + await signalRService.disconnectFromHub('loggingHub'); + } + }); + }); + describe('hub availability and reconnecting state', () => { const mockConfig: SignalRHubConnectConfig = { name: 'testHub', @@ -871,6 +908,59 @@ describe('SignalRService', () => { connectSpy.mockRestore(); }); + // A rebuilt connection has a new connection id outside every group and never raises + // onreconnected, so subscribers (department group, GeolocationConnect, chat arm) would stay + // silent without this event. + it('should raise the reconnected lifecycle event after rebuilding a closed connection', async () => { + jest.useFakeTimers(); + const scopedListener = jest.fn(); + const genericListener = jest.fn(); + signalRService.on('__hubReconnected:testHub', scopedListener); + signalRService.on('__hubReconnected', genericListener); + + try { + await signalRService.connectToHubWithEventingUrl(mockConfig); + const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; + + onCloseCallback(); + expect(scopedListener).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(5000); + + expect(mockRefreshAccessToken).toHaveBeenCalled(); + expect(mockConnection.start).toHaveBeenCalledTimes(2); + expect(scopedListener).toHaveBeenCalledTimes(1); + expect(scopedListener).toHaveBeenCalledWith('testHub'); + expect(genericListener).toHaveBeenCalledWith('testHub'); + } finally { + signalRService.off('__hubReconnected:testHub', scopedListener); + signalRService.off('__hubReconnected', genericListener); + jest.useRealTimers(); + } + }); + + it('should not raise the reconnected lifecycle event when the rebuild fails', async () => { + jest.useFakeTimers(); + const scopedListener = jest.fn(); + signalRService.on('__hubReconnected:testHub', scopedListener); + + try { + await signalRService.connectToHubWithEventingUrl(mockConfig); + const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; + mockConnection.start.mockRejectedValueOnce(new Error('still down')); + + onCloseCallback(); + await jest.advanceTimersByTimeAsync(5000); + + expect(mockConnection.start).toHaveBeenCalledTimes(2); + expect(scopedListener).not.toHaveBeenCalled(); + } finally { + signalRService.off('__hubReconnected:testHub', scopedListener); + await signalRService.disconnectFromHub(mockConfig.name); + jest.useRealTimers(); + } + }); + it('should reset reconnection attempts on successful reconnection', async () => { // Connect to hub await signalRService.connectToHubWithEventingUrl(mockConfig); diff --git a/src/services/app-reset.service.ts b/src/services/app-reset.service.ts index 5c64fbe4..de00bdf3 100644 --- a/src/services/app-reset.service.ts +++ b/src/services/app-reset.service.ts @@ -10,7 +10,7 @@ import { queryClient } from '@/api/common/api-provider'; import { registerSessionCleanupHandler } from '@/lib/auth/session-cleanup'; import { logger } from '@/lib/logging'; import { storage } from '@/lib/storage'; -import { removeActiveCallId, removeActiveUnitId, removeDeviceUuid } from '@/lib/storage/app'; +import { BASE_API_URL_STORAGE_KEY, removeActiveCallId, removeActiveUnitId, removeDeviceUuid } from '@/lib/storage/app'; import { locationService } from '@/services/location'; import { pushNotificationService } from '@/services/push-notification'; import { signalRService } from '@/services/signalr.service'; @@ -238,8 +238,10 @@ export const INITIAL_ROUTES_STATE = { error: null, }; -// Keys to preserve during storage clear (e.g., first-time flags) -const STORAGE_KEYS_TO_PRESERVE = ['IS_FIRST_TIME']; +// Keys to preserve during storage clear (e.g., first-time flags). The server +// URL is a device setting, not user data — wiping it would silently move an +// EU-Central (or self-hosted) device back to the default server on logout. +const STORAGE_KEYS_TO_PRESERVE = ['IS_FIRST_TIME', BASE_API_URL_STORAGE_KEY]; /** * Clears all persisted storage items except those in the preserve list @@ -363,6 +365,9 @@ export const teardownServices = async (): Promise => { lastUnitStatusTimestamp: 0, lastGeolocationMessage: null, lastGeolocationTimestamp: 0, + // The previous user's department positions must not move the next user's pins. + liveLocations: {}, + geolocationJoinCount: 0, error: null, }); diff --git a/src/services/location-fix.ts b/src/services/location-fix.ts index 00c868bd..c55cbb9b 100644 --- a/src/services/location-fix.ts +++ b/src/services/location-fix.ts @@ -28,20 +28,26 @@ const FIX_TIMEOUT_MS = 8000; /** A fix from the last minute is a fine answer for "where are you now" and costs no radio time. */ const LAST_KNOWN_MAX_AGE_MS = 60 * 1000; -interface TimedFix { - promise: Promise; +/** + * Ceiling on reading what the OS already knows. Both calls are normally instant; the bound only + * exists so a misbehaving platform call can never hold a status submission. + */ +const RECENT_READ_TIMEOUT_MS = 2000; + +interface TimedFix { + promise: Promise; cancel: () => void; } /** - * Races the live fix against a timer. The timer is cleared either way: leaving it pending keeps a - * Jest fake-timer test from settling and, in the app, holds a needless reference for its duration. + * Races a location call against a timer. The timer is cleared either way: leaving it pending keeps + * a Jest fake-timer test from settling and, in the app, holds a needless reference for its duration. */ -const withTimeout = (): TimedFix => { +const withTimeout = (work: Promise, timeoutMs: number): TimedFix => { let timer: ReturnType | null = null; const timeout = new Promise((resolve) => { - timer = setTimeout(() => resolve(null), FIX_TIMEOUT_MS); + timer = setTimeout(() => resolve(null), timeoutMs); }); const cancel = () => { @@ -52,20 +58,29 @@ const withTimeout = (): TimedFix => { }; return { - promise: Promise.race([ - Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }).catch((error) => { - logger.warn({ - message: 'Failed to acquire current position', - context: { error: error instanceof Error ? error.message : String(error) }, - }); - return null; - }), - timeout, - ]), + promise: Promise.race([work, timeout]), cancel, }; }; +const awaitWithTimeout = async (work: Promise, timeoutMs: number): Promise => { + const timed = withTimeout(work, timeoutMs); + try { + return await timed.promise; + } finally { + timed.cancel(); + } +}; + +const readCurrentPosition = (): Promise => + Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }).catch((error) => { + logger.warn({ + message: 'Failed to acquire current position', + context: { error: error instanceof Error ? error.message : String(error) }, + }); + return null; + }); + const readLastKnown = async (): Promise => { try { return await Location.getLastKnownPositionAsync({ maxAge: LAST_KNOWN_MAX_AGE_MS }); @@ -128,13 +143,7 @@ export const acquireLocationFix = async (): Promise => { }); } - const timedFix = withTimeout(); - let location: Location.LocationObject | null; - try { - location = await timedFix.promise; - } finally { - timedFix.cancel(); - } + let location = await awaitWithTimeout(readCurrentPosition(), FIX_TIMEOUT_MS); // A timed-out live fix is common indoors. A recent cached one is still a truthful answer and is // far better than refusing a GPS-required status outright. @@ -162,6 +171,35 @@ export const acquireLocationFix = async (): Promise => { return { outcome: 'acquired', location }; }; +/** + * A position for a status that does not require GPS: whatever the OS already holds from the last + * minute, or null. + * + * Unlike `acquireLocationFix` this never prompts for permission and never waits on the radio. A + * live fix can take the full `FIX_TIMEOUT_MS` indoors, and making every status wait that long for + * coordinates it does not need is how crews ended up with a sheet spinning on "Submitting". + */ +export const readRecentLocation = async (): Promise => { + const read = async (): Promise => { + const permission = await Location.getForegroundPermissionsAsync(); + if (permission.status !== 'granted') { + return null; + } + + return Location.getLastKnownPositionAsync({ maxAge: LAST_KNOWN_MAX_AGE_MS }); + }; + + try { + return await awaitWithTimeout(read(), RECENT_READ_TIMEOUT_MS); + } catch (error) { + logger.warn({ + message: 'Failed to read recent location for status', + context: { error: error instanceof Error ? error.message : String(error) }, + }); + return null; + } +}; + const FIX_ERROR_KEYS = { 'permission-denied': 'location.fix_permission_denied', 'services-disabled': 'location.fix_services_disabled', diff --git a/src/services/offline-event-manager.service.ts b/src/services/offline-event-manager.service.ts index 2177cbcd..bf216927 100644 --- a/src/services/offline-event-manager.service.ts +++ b/src/services/offline-event-manager.service.ts @@ -18,6 +18,7 @@ import { import { SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput'; import { SaveUnitStatusInput, SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; import { setOfflineQueueActivityListener, useOfflineQueueStore } from '@/stores/offline-queue/store'; +import { isNetworkError } from '@/utils/network'; /** * A replay the server refused outright. Sending the same payload again can only fail the same way, @@ -39,10 +40,31 @@ const getHttpStatus = (error: unknown): number | null => (axios.isAxiosError(err // refresh and 408/429 are timeouts/throttling, so those stay retryable like network failures. const isNonRetryableClientError = (status: number | null): status is number => status !== null && status >= 400 && status < 500 && status !== 401 && status !== 408 && status !== 429; +/** + * Still owed to the server: waiting, mid-send, or failed with retries left. A permanently failed + * event is parked for the user to see, not something later statuses should wait behind. + */ +const isUndelivered = (event: QueuedEvent): boolean => + event.status === QueuedEventStatus.PENDING || event.status === QueuedEventStatus.PROCESSING || (event.status === QueuedEventStatus.FAILED && event.retryCount < event.maxRetries); + +const isUndeliveredUnitStatusFor = (event: QueuedEvent, unitId: string): boolean => event.type === QueuedEventType.UNIT_STATUS && String(event.data?.unitId) === String(unitId) && isUndelivered(event); + +interface ProcessEventOptions { + /** + * The crew is waiting on this delivery (it gates a status they just submitted). A connectivity + * failure then says nothing about the event itself, so it goes back to pending with its retry + * budget intact instead of burning a retry the background processor would have spent later. + */ + interactive?: boolean; +} + class OfflineEventManager { private static instance: OfflineEventManager; private processingInterval: ReturnType | null = null; private isProcessing = false; + // The batch currently replaying events, whether started by the timer or by a submitted status. + // Only one may run at a time, or both would send the same event. + private currentRun: Promise | null = null; private appStateSubscription: { remove: () => void } | null = null; private readonly PROCESSING_INTERVAL = 10000; // 10 seconds private readonly MAX_CONCURRENT_EVENTS = 3; @@ -292,9 +314,6 @@ class OfflineEventManager { return; } - this.isProcessing = true; - store._setProcessing(true); - logger.info({ message: 'Processing queued events', context: { eventCount: pendingEvents.length }, @@ -305,25 +324,101 @@ class OfflineEventManager { // the server, showing a stale status/position as current. const eventsToProcess = [...pendingEvents].sort((a, b) => a.createdAt - b.createdAt).slice(0, this.MAX_CONCURRENT_EVENTS); - try { - for (const event of eventsToProcess) { - await this.processEvent(event); + await this.beginRun(async () => { + try { + for (const event of eventsToProcess) { + await this.processEvent(event); + } + } catch (error) { + logger.error({ + message: 'Error during batch event processing', + context: { error }, + }); } - } catch (error) { - logger.error({ - message: 'Error during batch event processing', - context: { error }, - }); - } finally { - this.isProcessing = false; - store._setProcessing(false); + }); + } + + /** + * Claim the processor for `task`. Callers must have checked `currentRun` is empty in the same + * synchronous block — there is no await between that check and this claim, so nothing can slip in. + */ + private beginRun(task: () => Promise): Promise { + this.isProcessing = true; + useOfflineQueueStore.getState()._setProcessing(true); + + const run = (async () => { + try { + await task(); + } finally { + this.isProcessing = false; + this.currentRun = null; + useOfflineQueueStore.getState()._setProcessing(false); + } + })(); + + this.currentRun = run; + return run; + } + + /** + * True only when the device has no network interface at all, so a request cannot succeed. + * + * Deliberately ignores the reachability flag: it reads false while still unknown, and on networks + * that block the reachability probe but reach Resgrid fine. + */ + public isDeviceOffline(): boolean { + return !useOfflineQueueStore.getState().isConnected; + } + + /** + * True when a unit status for this unit is still waiting to reach the server. + * + * The server takes a unit's most recently *inserted* state as current (not the latest + * timestamp), so a newer status sent past one of these would be reverted when it replays. + */ + public hasUndeliveredUnitStatuses(unitId: string): boolean { + return (useOfflineQueueStore.getState().queuedEvents ?? []).some((event) => isUndeliveredUnitStatusFor(event, unitId)); + } + + /** + * Send this unit's queued statuses now, oldest first, so a status the crew is submitting can + * follow them instead of overtaking them. + * + * Runs regardless of the NetInfo reachability flag — the caller is about to hit the network + * anyway, and reachability can read false on networks that reach Resgrid fine. Stops at the first + * status that cannot be delivered to keep the order. Returns true when nothing for the unit is + * still owed (delivered, or permanently rejected by the server), false otherwise. + */ + public async deliverQueuedUnitStatuses(unitId: string): Promise { + // Let a batch the timer already started finish rather than replay its events a second time. + // The loop re-checks after every wait, and the claim below follows it synchronously. + while (this.currentRun) { + await this.currentRun.catch(() => undefined); } + + let caughtUp = true; + + await this.beginRun(async () => { + const events = (useOfflineQueueStore.getState().queuedEvents ?? []).filter((event) => isUndeliveredUnitStatusFor(event, unitId)).sort((a, b) => a.createdAt - b.createdAt); + + for (const event of events) { + await this.processEvent(event, { interactive: true }); + + const after = useOfflineQueueStore.getState().getEventById(event.id); + if (after && isUndelivered(after)) { + caughtUp = false; + return; + } + } + }); + + return caughtUp; } /** * Process a single event */ - private async processEvent(event: QueuedEvent): Promise { + private async processEvent(event: QueuedEvent, options: ProcessEventOptions = {}): Promise { const store = useOfflineQueueStore.getState(); logger.debug({ @@ -384,6 +479,16 @@ class OfflineEventManager { return; } + if (options.interactive && isNetworkError(error)) { + store.updateEventStatus(event.id, QueuedEventStatus.PENDING, errorMessage); + + logger.warn({ + message: 'Queued event could not be delivered ahead of a new submission, left pending', + context: { eventId: event.id, type: event.type, error: errorMessage }, + }); + return; + } + store.updateEventStatus(event.id, QueuedEventStatus.FAILED, errorMessage); logger.error({ diff --git a/src/services/signalr.service.ts b/src/services/signalr.service.ts index ef73d22f..aa9ce0a5 100644 --- a/src/services/signalr.service.ts +++ b/src/services/signalr.service.ts @@ -4,6 +4,12 @@ import { Platform } from 'react-native'; import { logger } from '@/lib/logging'; import useAuthStore from '@/stores/auth/store'; +// Location pushes arrive every few seconds for every unit and person, so they are not logged at all: +// a per-message line would flood the console and push useful breadcrumbs out of error reports. +const UNLOGGED_HUB_METHODS = new Set(['onunitlocationupdated', 'onpersonnellocationupdated']); + +export const isUnloggedHubMethod = (method: string): boolean => UNLOGGED_HUB_METHODS.has(method.toLowerCase()); + export interface SignalRHubConfig { name: string; url: string; @@ -249,10 +255,13 @@ class SignalRService { }); connection.on(method, (...args: unknown[]) => { - logger.debug({ - message: `Received ${method} message from hub: ${config.name}`, - context: { method, args }, - }); + // Never log payloads: they carry personal data (chat, call details, precise coordinates). + if (!isUnloggedHubMethod(method)) { + logger.debug({ + message: `Received ${method} message from hub: ${config.name}`, + context: { method }, + }); + } this.handleMessage(config.name, method, args); }); }); @@ -404,10 +413,13 @@ class SignalRService { }); connection.on(method, (...args: unknown[]) => { - logger.debug({ - message: `Received ${method} message from hub: ${config.name}`, - context: { method, args }, - }); + // Never log payloads: they carry personal data (chat, call details, precise coordinates). + if (!isUnloggedHubMethod(method)) { + logger.debug({ + message: `Received ${method} message from hub: ${config.name}`, + context: { method }, + }); + } this.handleMessage(config.name, method, args); }); }); @@ -538,6 +550,14 @@ class SignalRService { logger.info({ message: `Successfully reconnected to hub: ${hubName} after ${currentAttempts} attempts`, }); + + // The rebuilt connection has a new connection id that belongs to no groups, and a fresh + // connection never raises onreconnected. Emit the same lifecycle event so subscribers + // re-join their groups and resync exactly as they do after an automatic reconnect — + // otherwise the socket is up but silent until the next background/resume cycle. + if (this.connections.has(hubName)) { + this.emitHubLifecycle(SignalRService.HUB_RECONNECTED_EVENT, hubName); + } } catch (error) { // Attempt failed — the old connection object is gone, so no further // onclose event will fire. We MUST reschedule here or the hub stays diff --git a/src/stores/app/__tests__/core-store.test.ts b/src/stores/app/__tests__/core-store.test.ts index fd143c7c..36bff71d 100644 --- a/src/stores/app/__tests__/core-store.test.ts +++ b/src/stores/app/__tests__/core-store.test.ts @@ -19,6 +19,8 @@ jest.mock('@/lib/storage/app', () => ({ getActiveCallId: jest.fn(), setActiveUnitId: jest.fn(), setActiveCallId: jest.fn(), + removeActiveUnitId: jest.fn(), + removeActiveCallId: jest.fn(), })); jest.mock('@/lib/logging', () => ({ @@ -60,8 +62,8 @@ jest.mock('@/lib/storage', () => ({ })); // Import after mocks -import { useCoreStore } from '../core-store'; -import { getActiveUnitId, getActiveCallId } from '@/lib/storage/app'; +import { UnitListUnavailableError, useCoreStore } from '../core-store'; +import { getActiveUnitId, getActiveCallId, removeActiveUnitId } from '@/lib/storage/app'; import { getConfig } from '@/api/config'; import { logger } from '@/lib/logging'; import { GetConfigResultData } from '@/models/v4/configs/getConfigResultData'; @@ -380,6 +382,104 @@ describe('Core Store', () => { }); }); + // The selection must survive a force close: init() restores it from the persisted id. + describe('restoring the selected unit on launch', () => { + const savedUnit = { UnitId: 'unit-1', Name: 'Engine 6', Type: '3' } as any; + const unitStatuses = [{ UnitType: '3', StatusId: 's3', Statuses: [{ Text: 'Available' }] }] as any[]; + + const unitsStore = (overrides: Record) => ({ + fetchUnits: jest.fn(async () => undefined), + units: [], + unitStatuses: [], + error: null, + hasLoaded: true, + ...overrides, + }); + + beforeEach(() => { + mockGetActiveUnitId.mockReturnValue('unit-1'); + mockGetActiveCallId.mockReturnValue(null); + mockGetConfig.mockResolvedValue({ Data: { EventingUrl: 'https://eventing.example.com/' } as GetConfigResultData } as any); + (getUnitStatus as jest.Mock).mockImplementation(async () => ({ Data: { UnitId: 'unit-1', State: 'Available' } })); + }); + + it('restores the persisted unit and its statuses', async () => { + (useUnitsStore.getState as jest.Mock).mockReturnValue(unitsStore({ units: [savedUnit], unitStatuses })); + + await useCoreStore.getState().init(); + + expect(useCoreStore.getState().activeUnitId).toBe('unit-1'); + expect(useCoreStore.getState().activeUnit).toEqual(savedUnit); + expect(useCoreStore.getState().activeStatuses).toEqual(unitStatuses[0]); + expect(useCoreStore.getState().isInitialized).toBe(true); + }); + + it('keeps the selection and fails init (so it is retried) when the unit list cannot be loaded', async () => { + (useUnitsStore.getState as jest.Mock).mockReturnValue(unitsStore({ error: 'Network Error' })); + + await expect(useCoreStore.getState().init()).rejects.toBeInstanceOf(UnitListUnavailableError); + + expect(removeActiveUnitId).not.toHaveBeenCalled(); + expect(useCoreStore.getState().activeUnitId).toBe('unit-1'); + expect(useCoreStore.getState().isInitialized).toBe(false); + expect(useCoreStore.getState().isLoading).toBe(false); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('treats a list that has not loaded yet as unavailable, not as the unit being deleted', async () => { + (useUnitsStore.getState as jest.Mock).mockReturnValue(unitsStore({ hasLoaded: false })); + + await expect(useCoreStore.getState().setActiveUnit('unit-1')).rejects.toBeInstanceOf(UnitListUnavailableError); + + expect(removeActiveUnitId).not.toHaveBeenCalled(); + }); + + it('clears the selection when the loaded list no longer contains the unit', async () => { + (useUnitsStore.getState as jest.Mock).mockReturnValue(unitsStore({ units: [{ ...savedUnit, UnitId: 'unit-2' }] })); + + await useCoreStore.getState().init(); + + expect(removeActiveUnitId).toHaveBeenCalled(); + expect(useCoreStore.getState().activeUnitId).toBeNull(); + expect(useCoreStore.getState().activeUnit).toBeNull(); + expect(useCoreStore.getState().isInitialized).toBe(true); + }); + }); + + // Runs after every successful status save to pick up the new state. + describe('setActiveUnitWithFetch', () => { + const currentUnit = { UnitId: 'unit-1', Name: 'Engine 6', Type: '3' } as any; + + beforeEach(() => { + useCoreStore.setState({ activeUnitId: 'unit-1', activeUnit: currentUnit }); + (getUnitStatus as jest.Mock).mockImplementation(async () => ({ Data: { UnitId: 'unit-1', State: 'Responding' } })); + }); + + it('refreshes the unit and its status from the fetched data', async () => { + const refreshedUnit = { ...currentUnit, Name: 'Engine 6 (renamed)' }; + (useUnitsStore.getState as jest.Mock).mockReturnValue({ fetchUnits: jest.fn(async () => undefined), units: [refreshedUnit], unitStatuses: [] }); + + await useCoreStore.getState().setActiveUnitWithFetch('unit-1'); + + expect(useCoreStore.getState().activeUnit).toEqual(refreshedUnit); + expect(useCoreStore.getState().activeUnitStatus).toEqual({ UnitId: 'unit-1', State: 'Responding' }); + expect(useCoreStore.getState().isLoading).toBe(false); + }); + + it('keeps the current unit when the refreshed list does not contain it', async () => { + // fetchUnits() swallows its failures, so an empty list is what a failed refresh looks like. + (useUnitsStore.getState as jest.Mock).mockReturnValue({ fetchUnits: jest.fn(async () => undefined), units: [], unitStatuses: [] }); + + await useCoreStore.getState().setActiveUnitWithFetch('unit-1'); + + // Clearing it would silently disable every status submit until the app restarts. + expect(useCoreStore.getState().activeUnit).toBe(currentUnit); + expect(useCoreStore.getState().activeUnitStatus).toEqual({ UnitId: 'unit-1', State: 'Responding' }); + expect(useCoreStore.getState().isLoading).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'Active unit missing from refreshed units, keeping the current unit' })); + }); + }); + describe('Store State', () => { it('should have correct initial state', () => { const { result } = renderHook(() => useCoreStore()); diff --git a/src/stores/app/core-store.ts b/src/stores/app/core-store.ts index 4f94b211..0f744697 100644 --- a/src/stores/app/core-store.ts +++ b/src/stores/app/core-store.ts @@ -21,6 +21,23 @@ import { useCallsStore } from '../calls/store'; //import { useRolesStore } from '../roles/store'; import { useUnitsStore } from '../units/store'; +/** + * The unit list could not be loaded, so whether a unit still exists is unknown. + * + * setActiveUnit throws this rather than treating the unit as deleted: during init() it fails the + * initialization so the app layout retries it (with backoff, and again on foreground), and the + * unit picker reports the selection as failed instead of silently dropping it. + */ +export class UnitListUnavailableError extends Error { + constructor( + public readonly unitId: string, + reason: string | null + ) { + super(`Unit list unavailable while setting active unit ${unitId}${reason ? `: ${reason}` : ''}`); + this.name = 'UnitListUnavailableError'; + } +} + interface CoreState { activeUnitId: string | null; activeUnit: UnitResultData | null; @@ -117,10 +134,11 @@ export const useCoreStore = create()( isLoading: false, isInitializing: false, }); - // A network failure here has already been surfaced by fetchConfig; keep it - // at warn so the same transient, recoverable error is not reported to Sentry - // multiple times as it bubbles up the call stack. - if (isNetworkError(error)) { + // A network failure here has already been surfaced by fetchConfig (and an + // unavailable unit list by the units store); keep it at warn so the same + // transient, recoverable error is not reported to Sentry multiple times as it + // bubbles up the call stack. + if (isNetworkError(error) || error instanceof UnitListUnavailableError) { logger.warn({ message: 'Failed to init core app data due to network connectivity', context: { error }, @@ -139,9 +157,16 @@ export const useCoreStore = create()( try { await setActiveUnitId(unitId); await useUnitsStore.getState().fetchUnits(); - const units = useUnitsStore.getState().units; - const unitStatuses = useUnitsStore.getState().unitStatuses; + const { units, unitStatuses, error: unitsError, hasLoaded } = useUnitsStore.getState(); const activeUnit = units.find((unit) => unit.UnitId === unitId); + + if (!activeUnit && (unitsError || !hasLoaded)) { + // fetchUnits() swallows its failures, so a missing unit here usually means the list could + // not be loaded, not that the unit was deleted. Clearing the selection on that is how a + // crew reopening the app with poor signal lost their unit — keep it and fail instead. + throw new UnitListUnavailableError(unitId, unitsError); + } + if (activeUnit) { // fetchUnits() above already fetched /Statuses/GetAllUnitStatuses and // stored the identical payload as unitStatuses — reuse it instead of @@ -167,8 +192,8 @@ export const useCoreStore = create()( isLoading: false, }); } else { - // Persisted unit no longer exists (deleted/decommissioned) — clear - // the stale id and ALWAYS reset isLoading or consumers spin forever. + // The list loaded and the persisted unit is not in it (deleted/decommissioned) — + // clear the stale id and ALWAYS reset isLoading or consumers spin forever. logger.warn({ message: 'Active unit not found in fetched units, clearing stale selection', context: { unitId }, @@ -206,6 +231,16 @@ export const useCoreStore = create()( //await useRolesStore.getState().fetchRolesForUnit(unitId); } catch (error) { set({ error: 'Failed to set active unit', isLoading: false }); + + if (error instanceof UnitListUnavailableError) { + // The underlying fetch failure is already reported by the units store. + logger.warn({ + message: 'Unit list unavailable, keeping the selected unit for a retry', + context: { unitId, error: error.message }, + }); + throw error; + } + if (isNetworkError(error)) { logger.warn({ message: 'Failed to set active unit due to network connectivity', @@ -229,8 +264,18 @@ export const useCoreStore = create()( const unitStatus = await getUnitStatus(unitId); + // fetchUnits() swallows its own failures, so the list can be empty or stale here. Writing + // `undefined` over the unit we already hold would silently disable every status submit + // (they all require an active unit) until the app restarts — keep the one we have. + if (!activeUnit) { + logger.warn({ + message: 'Active unit missing from refreshed units, keeping the current unit', + context: { unitId, unitCount: units.length }, + }); + } + set({ - activeUnit: activeUnit, + ...(activeUnit ? { activeUnit } : {}), activeUnitStatus: unitStatus.Data, isLoading: false, }); diff --git a/src/stores/roles/__tests__/store.test.ts b/src/stores/roles/__tests__/store.test.ts new file mode 100644 index 00000000..4bbf6b1a --- /dev/null +++ b/src/stores/roles/__tests__/store.test.ts @@ -0,0 +1,44 @@ +jest.mock('@/api/personnel/personnel', () => ({ getAllPersonnelInfos: jest.fn() })); +jest.mock('@/api/units/unitRoles', () => ({ + getAllUnitRolesAndAssignmentsForDepartment: jest.fn(), + getRoleAssignmentsForUnit: jest.fn(), + setRoleAssignmentsForUnit: jest.fn(), +})); +jest.mock('@/stores/app/core-store', () => ({ useCoreStore: { getState: jest.fn(() => ({ activeUnitId: 'unit1' })) } })); + +import { setRoleAssignmentsForUnit } from '@/api/units/unitRoles'; + +import { useRolesStore } from '../store'; + +const mockSetRoleAssignmentsForUnit = setRoleAssignmentsForUnit as jest.Mock; + +const input = { UnitId: 'unit1', Roles: [{ RoleId: 'role1', UserId: 'user1', Name: '' }] }; + +describe('useRolesStore.assignRoles', () => { + beforeEach(() => { + jest.clearAllMocks(); + useRolesStore.setState({ isLoading: false, error: null }); + }); + + it('saves the assignments and clears the loading flag', async () => { + mockSetRoleAssignmentsForUnit.mockResolvedValue({}); + + await expect(useRolesStore.getState().assignRoles(input as any)).resolves.toBeUndefined(); + + expect(mockSetRoleAssignmentsForUnit).toHaveBeenCalledWith(input); + expect(useRolesStore.getState().isLoading).toBe(false); + }); + + // Swallowing this made every failed save report "saved successfully". + it('rethrows a failed save so the caller can report it', async () => { + const failure = new Error('Request failed with status code 500'); + mockSetRoleAssignmentsForUnit.mockRejectedValue(failure); + + await expect(useRolesStore.getState().assignRoles(input as any)).rejects.toBe(failure); + + expect(useRolesStore.getState().isLoading).toBe(false); + // `error` is the load-failure state the roles sheet shows instead of the list; a save failure + // must not hide the crew's unsaved picks behind it. + expect(useRolesStore.getState().error).toBeNull(); + }); +}); diff --git a/src/stores/roles/store.ts b/src/stores/roles/store.ts index e912c397..2cd2ca86 100644 --- a/src/stores/roles/store.ts +++ b/src/stores/roles/store.ts @@ -91,8 +91,13 @@ export const useRolesStore = create((set) => ({ set({ isLoading: true, error: null }); try { await setRoleAssignmentsForUnit(data); + set({ isLoading: false }); } catch (error) { - set({ error: 'Failed to assign user to role', isLoading: false }); + // Rethrow so the caller can tell the crew the save failed — swallowing it here made every + // failed save report success. `error` is deliberately left alone: it is the load-failure + // state, which the roles sheet shows in place of the roles list, hiding the unsaved picks. + set({ isLoading: false }); + throw error; } }, fetchAllForUnit: async (unitId: string) => { diff --git a/src/stores/signalr/__tests__/signalr-store.test.ts b/src/stores/signalr/__tests__/signalr-store.test.ts index e884efc6..bb4c3590 100644 --- a/src/stores/signalr/__tests__/signalr-store.test.ts +++ b/src/stores/signalr/__tests__/signalr-store.test.ts @@ -15,6 +15,7 @@ jest.mock('@/services/signalr.service', () => { invoke: jest.fn().mockResolvedValue(undefined), on: jest.fn(), removeAllListeners: jest.fn(), + isHubAvailable: jest.fn().mockReturnValue(false), connectToHub: jest.fn().mockResolvedValue(undefined), disconnectAll: jest.fn().mockResolvedValue(undefined), }; @@ -130,6 +131,8 @@ describe('useSignalRStore', () => { isGeolocationHubConnected: false, lastGeolocationMessage: null, lastGeolocationTimestamp: 0, + liveLocations: {}, + geolocationJoinCount: 0, error: null, }); @@ -152,6 +155,7 @@ describe('useSignalRStore', () => { (signalRService.disconnectFromHub as jest.Mock).mockResolvedValue(undefined); (signalRService.invoke as jest.Mock).mockResolvedValue(undefined); (signalRService.on as jest.Mock).mockImplementation(() => {}); + (signalRService.isHubAvailable as jest.Mock).mockReturnValue(false); }); describe('Basic Store Functionality', () => { @@ -173,6 +177,8 @@ describe('useSignalRStore', () => { expect(result.current.lastUpdateTimestamps).toEqual({}); expect(result.current.lastUnitStatusMessage).toBeNull(); expect(result.current.lastUnitStatusTimestamp).toBe(0); + expect(result.current.liveLocations).toEqual({}); + expect(result.current.geolocationJoinCount).toBe(0); expect(result.current.error).toBeNull(); }); }); @@ -510,25 +516,262 @@ describe('useSignalRStore', () => { expect(result.current.error).toEqual(new Error('EventingUrl not available in config. Please ensure config is loaded first.')); }); - it('should register no-op location handlers that do not write to the store', async () => { + const captureHandlers = () => { const handlers: Record void> = {}; - (signalRService.on as jest.Mock).mockImplementation((event: string, handler: (message: unknown) => void) => { + (signalRService.on as jest.Mock).mockImplementation((event: string, handler: (message?: unknown) => void) => { handlers[event] = handler; }); + return handlers; + }; + + const geolocationConnectCalls = () => (signalRService.invoke as jest.Mock).mock.calls.filter((call) => call[1] === 'GeolocationConnect'); + + const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + it('should invoke GeolocationConnect with no arguments once the connection is up', async () => { const { result } = renderHook(() => useSignalRStore()); await act(async () => { await result.current.connectGeolocationHub(); }); + expect(signalRService.connectToHubWithEventingUrl).toHaveBeenCalledWith({ + name: 'geolocationHub', + eventingUrl: mockEventingUrl, + hubName: 'geolocationHub', + methods: ['onPersonnelLocationUpdated', 'onUnitLocationUpdated', 'onGeolocationConnect'], + }); + // Exactly (hub, method): an extra argument makes the server reject the zero-argument hub method. + expect(signalRService.invoke).toHaveBeenCalledWith('geolocationHub', 'GeolocationConnect'); + expect(geolocationConnectCalls()[0]).toHaveLength(2); + const connectOrder = (signalRService.connectToHubWithEventingUrl as jest.Mock).mock.invocationCallOrder[0]; + const joinOrder = (signalRService.invoke as jest.Mock).mock.invocationCallOrder[0]; + expect(joinOrder).toBeGreaterThan(connectOrder); + expect(result.current.geolocationJoinCount).toBe(1); + expect(result.current.error).toBeNull(); + }); + + it('should register every listener BEFORE starting the connection', async () => { + const registeredBeforeConnect: string[] = []; + let connectStarted = false; + (signalRService.on as jest.Mock).mockImplementation((event: string) => { + if (!connectStarted) { + registeredBeforeConnect.push(event); + } + }); + (signalRService.connectToHubWithEventingUrl as jest.Mock).mockImplementation(async () => { + connectStarted = true; + }); + + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectGeolocationHub(); + }); + + expect(registeredBeforeConnect).toEqual( + expect.arrayContaining([ + 'onPersonnelLocationUpdated', + 'onUnitLocationUpdated', + 'onGeolocationConnect', + '__hubDisconnected:geolocationHub', + '__hubReconnecting:geolocationHub', + '__hubReconnected:geolocationHub', + ]) + ); + expect(signalRService.removeAllListeners).toHaveBeenCalledWith('__hubReconnected:geolocationHub'); + }); + + it('should report connected only once the server answers onGeolocationConnect, and not after the hub drops', async () => { + const handlers = captureHandlers(); + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectGeolocationHub(); + }); + + // Connected transport + successful invoke is not yet "connected" for the settings screen. + expect(result.current.isGeolocationHubConnected).toBe(false); + act(() => { - handlers['onPersonnelLocationUpdated']({ Latitude: 1, Longitude: 2 }); - handlers['onUnitLocationUpdated']({ Latitude: 3, Longitude: 4 }); + handlers['onGeolocationConnect']('connection-id'); }); + expect(result.current.isGeolocationHubConnected).toBe(true); - expect(result.current.lastGeolocationMessage).toBeNull(); - expect(result.current.lastGeolocationTimestamp).toBe(0); + act(() => { + handlers['__hubReconnecting:geolocationHub'](); + }); + expect(result.current.isGeolocationHubConnected).toBe(false); + + act(() => { + handlers['onGeolocationConnect']('connection-id'); + }); + act(() => { + handlers['__hubDisconnected:geolocationHub'](); + }); + expect(result.current.isGeolocationHubConnected).toBe(false); + }); + + it('should re-invoke GeolocationConnect after every reconnect (automatic reconnect or service rebuild)', async () => { + const handlers = captureHandlers(); + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectGeolocationHub(); + }); + expect(geolocationConnectCalls()).toHaveLength(1); + + await act(async () => { + handlers['__hubReconnecting:geolocationHub'](); + handlers['__hubReconnected:geolocationHub'](); + await flushPromises(); + }); + expect(geolocationConnectCalls()).toHaveLength(2); + + // The service emits the same event after rebuilding the connection once automatic reconnect gave up. + await act(async () => { + handlers['__hubDisconnected:geolocationHub'](); + handlers['__hubReconnected:geolocationHub'](); + await flushPromises(); + }); + expect(geolocationConnectCalls()).toHaveLength(3); + geolocationConnectCalls().forEach((call) => expect(call).toEqual(['geolocationHub', 'GeolocationConnect'])); + + // Each successful re-join counts, which is what triggers the maps' catch-up refetch. + expect(result.current.geolocationJoinCount).toBe(3); + }); + + it('should not count a join that completes after its connection dropped', async () => { + const handlers = captureHandlers(); + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectGeolocationHub(); + }); + expect(result.current.geolocationJoinCount).toBe(1); + + let resolveJoin: () => void = () => {}; + (signalRService.invoke as jest.Mock).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveJoin = resolve; + }) + ); + + await act(async () => { + handlers['__hubReconnected:geolocationHub'](); + await flushPromises(); + }); + + await act(async () => { + handlers['__hubDisconnected:geolocationHub'](); + resolveJoin(); + await flushPromises(); + }); + + expect(result.current.geolocationJoinCount).toBe(1); + }); + + it('should retry a failed GeolocationConnect and count the join once it succeeds', async () => { + jest.useFakeTimers(); + try { + const joinError = new Error('join failed'); + (signalRService.invoke as jest.Mock).mockRejectedValueOnce(joinError); + + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectGeolocationHub(); + }); + + expect(result.current.error).toEqual(joinError); + expect(result.current.geolocationJoinCount).toBe(0); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'Failed to join the geolocation hub department group' })); + + await act(async () => { + jest.advanceTimersByTime(5000); + }); + + expect(geolocationConnectCalls()).toHaveLength(2); + expect(result.current.geolocationJoinCount).toBe(1); + } finally { + jest.useRealTimers(); + } + }); + + it('should not let a stale connected flag block a repair', async () => { + useSignalRStore.setState({ isGeolocationHubConnected: true }); + (signalRService.isHubAvailable as jest.Mock).mockReturnValue(false); + + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectGeolocationHub(); + }); + + expect(signalRService.connectToHubWithEventingUrl).toHaveBeenCalled(); + expect(signalRService.invoke).toHaveBeenCalledWith('geolocationHub', 'GeolocationConnect'); + }); + + it('should skip reconnecting while the joined connection is still held by the service', async () => { + useSignalRStore.setState({ isGeolocationHubConnected: true }); + (signalRService.isHubAvailable as jest.Mock).mockReturnValue(true); + + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectGeolocationHub(); + }); + + expect(signalRService.connectToHubWithEventingUrl).not.toHaveBeenCalled(); + expect(signalRService.invoke).not.toHaveBeenCalled(); + }); + + it('should store live positions per pin from camelCase, PascalCase and JSON-string pushes', async () => { + const handlers = captureHandlers(); + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectGeolocationHub(); + }); + + // One frame's worth of pushes dispatched back to back: none may be lost. + act(() => { + handlers['onUnitLocationUpdated']({ departmentId: 1, unitId: '12', latitude: 39.5, longitude: -119.8, recordId: 'r1', timestamp: '2026-09-25T14:03:11.123Z' }); + handlers['onUnitLocationUpdated']({ UnitId: '13', Latitude: 39.6, Longitude: -119.7 }); + handlers['onPersonnelLocationUpdated'](JSON.stringify({ userId: 'ABCDEF01-2345-6789-ABCD-EF0123456789', latitude: 39.7, longitude: -119.6 })); + }); + + const live = result.current.liveLocations; + expect(Object.keys(live).sort()).toEqual(['pabcdef01-2345-6789-abcd-ef0123456789', 'u12', 'u13']); + expect(live.u12).toEqual(expect.objectContaining({ pinId: 'u12', latitude: 39.5, longitude: -119.8, timestamp: Date.UTC(2026, 8, 25, 14, 3, 11, 123) })); + expect(live.u13).toEqual(expect.objectContaining({ latitude: 39.6, longitude: -119.7, timestamp: null })); + expect(live.u12.receivedAt).toBeGreaterThan(0); + }); + + it('should ignore an older fix for a pin and leave the store untouched for unusable pushes', async () => { + const handlers = captureHandlers(); + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectGeolocationHub(); + }); + + act(() => { + handlers['onUnitLocationUpdated']({ unitId: '12', latitude: 40, longitude: -120, timestamp: '2026-09-25T14:05:00Z' }); + }); + const afterNewest = result.current.liveLocations; + + act(() => { + // A replayed, older fix for the same unit. + handlers['onUnitLocationUpdated']({ unitId: '12', latitude: 39, longitude: -121, timestamp: '2026-09-25T14:04:00Z' }); + handlers['onUnitLocationUpdated']({ unitId: '12', latitude: 0, longitude: 0 }); + handlers['onUnitLocationUpdated']({ unitId: '12', latitude: 91, longitude: 10 }); + handlers['onPersonnelLocationUpdated']('not json'); + }); + + expect(result.current.liveLocations).toBe(afterNewest); + expect(result.current.liveLocations.u12.latitude).toBe(40); }); }); diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index 50a3124a..5fec29af 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -1,7 +1,8 @@ -import { create } from 'zustand'; +import { create, type StoreApi } from 'zustand'; import { useAuthStore } from '@/lib'; import { Env } from '@/lib/env'; +import { type LiveLocationKind, type LiveLocations, mergeLiveLocation, parseLiveLocationPayload } from '@/lib/live-locations'; import { logger } from '@/lib/logging'; import { SignalRService, signalRService } from '@/services/signalr.service'; @@ -245,9 +246,19 @@ interface SignalRState { /** Raw payload of the latest unitStatusUpdated message (no JSON round-trip). */ lastUnitStatusMessage: unknown; lastUnitStatusTimestamp: number; + /** True only once the geolocation hub has answered `GeolocationConnect` with `onGeolocationConnect`. */ isGeolocationHubConnected: boolean; + /** @deprecated Never written — a single slot drops all but one of the positions in a frame. Use `liveLocations`. */ lastGeolocationMessage: unknown; + /** @deprecated Never written. Use `liveLocations`. */ lastGeolocationTimestamp: number; + /** Latest pushed position per map pin id (`u{UnitId}` / `p{userId}`), newest fix wins. */ + liveLocations: LiveLocations; + /** + * Incremented after every successful `GeolocationConnect`. The first join of a session is covered by + * the map's own fetch; any later one follows a gap in which pushes were missed, so maps refetch. + */ + geolocationJoinCount: number; isChatHubConnected: boolean; error: Error | null; connectUpdateHub: () => Promise; @@ -296,6 +307,111 @@ const joinDepartmentGroup = async (): Promise => { await signalRService.invoke(Env.CHANNEL_HUB_NAME, 'connect', departmentId); }; +type SignalRSetState = StoreApi['setState']; + +// Joining the department group on the geolocation hub. Membership is per connection id, so every new +// connection — first start, automatic reconnect, or the service's rebuild after onclose — has to +// invoke GeolocationConnect again or the map silently stops receiving positions. +const GEOLOCATION_JOIN_RETRY_MS = 5000; +const GEOLOCATION_JOIN_MAX_ATTEMPTS = 3; +let geolocationJoinTimer: ReturnType | null = null; +let geolocationJoinAttempts = 0; +// Bumped whenever the connection a join was aimed at goes away (disconnect, reconnecting, a fresh +// reconnected connection), so a join still in flight against the old connection completes as a no-op +// instead of counting as a join or scheduling retries. +let geolocationConnectionGeneration = 0; +// The join in flight for the current generation, so overlapping callers share one invoke. +let geolocationJoinOperation: { generation: number; promise: Promise } | null = null; + +function stopGeolocationJoinRetry(): void { + if (geolocationJoinTimer) { + clearTimeout(geolocationJoinTimer); + geolocationJoinTimer = null; + } +} + +/** Invalidate the current geolocation connection's join (in flight or scheduled). */ +function retireGeolocationConnection(): void { + geolocationConnectionGeneration += 1; + stopGeolocationJoinRetry(); + geolocationJoinAttempts = 0; +} + +async function runGeolocationJoin(set: SignalRSetState, generation: number): Promise { + stopGeolocationJoinRetry(); + + try { + // No arguments: the hub method takes none, and SignalR rejects an invocation whose argument + // count does not match the target. + await signalRService.invoke(Env.REALTIME_GEO_HUB_NAME, 'GeolocationConnect'); + } catch (error) { + // A failure against a connection that is already gone must not schedule retries. + if (generation !== geolocationConnectionGeneration) { + return; + } + geolocationJoinAttempts += 1; + logger.warn({ + message: 'Failed to join the geolocation hub department group', + context: { error, attempt: geolocationJoinAttempts, maxAttempts: GEOLOCATION_JOIN_MAX_ATTEMPTS }, + }); + // A failed join is silent and total: the socket is up but receives nothing. Retry a bounded + // number of times rather than waiting for a background/resume cycle. + if (geolocationJoinAttempts < GEOLOCATION_JOIN_MAX_ATTEMPTS) { + geolocationJoinTimer = setTimeout(() => { + geolocationJoinTimer = null; + void joinGeolocationGroup(set).catch(() => { + // runGeolocationJoin already logged and scheduled the next retry. + }); + }, GEOLOCATION_JOIN_RETRY_MS); + } else { + logger.error({ + message: 'Giving up joining the geolocation hub department group; the next connectGeolocationHub will retry', + context: { attempts: geolocationJoinAttempts }, + }); + } + throw error; + } + + if (generation !== geolocationConnectionGeneration) { + return; + } + geolocationJoinAttempts = 0; + set((state) => ({ geolocationJoinCount: state.geolocationJoinCount + 1 })); +} + +function joinGeolocationGroup(set: SignalRSetState): Promise { + const generation = geolocationConnectionGeneration; + if (geolocationJoinOperation && geolocationJoinOperation.generation === generation) { + return geolocationJoinOperation.promise; + } + const promise = runGeolocationJoin(set, generation).finally(() => { + if (geolocationJoinOperation?.promise === promise) { + geolocationJoinOperation = null; + } + }); + geolocationJoinOperation = { generation, promise }; + return promise; +} + +/** + * Handler for a location push. Records the position per pin (never in a single slot: SignalR + * dispatches a frame's messages synchronously and React batches the renders, so a single slot would + * keep only the last one) and drops fixes older than the one already held. + */ +const recordLiveLocation = + (set: SignalRSetState, kind: LiveLocationKind) => + (payload: unknown): void => { + const update = parseLiveLocationPayload(kind, payload); + if (!update) { + logger.debug({ message: 'Ignoring unusable geolocation push', context: { kind } }); + return; + } + set((state) => { + const liveLocations = mergeLiveLocation(state.liveLocations, update); + return liveLocations === state.liveLocations ? state : { liveLocations }; + }); + }; + export const useSignalRStore = create((set, get) => ({ isUpdateHubConnected: false, lastUpdateMessage: null, @@ -306,6 +422,8 @@ export const useSignalRStore = create((set, get) => ({ isGeolocationHubConnected: false, lastGeolocationMessage: null, lastGeolocationTimestamp: 0, + liveLocations: {}, + geolocationJoinCount: 0, isChatHubConnected: false, error: null, connectUpdateHub: async () => { @@ -560,7 +678,9 @@ export const useSignalRStore = create((set, get) => ({ }, connectGeolocationHub: async () => { try { - if (get().isGeolocationHubConnected) { + // The flag alone must never block a repair: only skip when the service still holds the + // connection the flag was set for. + if (get().isGeolocationHubConnected && signalRService.isHubAvailable(Env.REALTIME_GEO_HUB_NAME)) { return; } @@ -579,27 +699,42 @@ export const useSignalRStore = create((set, get) => ({ return; } - // Remove any previously registered handlers to prevent accumulation + // Remove any previously registered handlers to prevent accumulation. Lifecycle events use + // hub-scoped names so this never wipes another hub's listeners. const geoHubDisconnected = `${SignalRService.HUB_DISCONNECTED_EVENT}:${Env.REALTIME_GEO_HUB_NAME}`; - const geoEvents = ['onPersonnelLocationUpdated', 'onUnitLocationUpdated', 'onGeolocationConnect', geoHubDisconnected]; + const geoHubReconnecting = `${SignalRService.HUB_RECONNECTING_EVENT}:${Env.REALTIME_GEO_HUB_NAME}`; + const geoHubReconnected = `${SignalRService.HUB_RECONNECTED_EVENT}:${Env.REALTIME_GEO_HUB_NAME}`; + const geoEvents = ['onPersonnelLocationUpdated', 'onUnitLocationUpdated', 'onGeolocationConnect', geoHubDisconnected, geoHubReconnecting, geoHubReconnected]; geoEvents.forEach((event) => signalRService.removeAllListeners(event)); // Register listeners BEFORE starting the connection so an early // onGeolocationConnect from the server cannot be dropped, which would // leave isGeolocationHubConnected stuck at false. - // NOTE: no per-message store writes here. Geolocation messages fire per - // unit per location cycle and nothing in the app consumes them — writing - // them to the store (previously JSON.stringify'd on every message) was - // pure CPU/render churn. Register no-op listeners so the hub methods stay - // subscribed without store updates. - signalRService.on('onPersonnelLocationUpdated', () => {}); - signalRService.on('onUnitLocationUpdated', () => {}); + // Positions go into the store per pin; the live maps apply them to their own pins. + signalRService.on('onPersonnelLocationUpdated', recordLiveLocation(set, 'personnel')); + signalRService.on('onUnitLocationUpdated', recordLiveLocation(set, 'unit')); signalRService.on(geoHubDisconnected, () => { + retireGeolocationConnection(); + set({ isGeolocationHubConnected: false }); + }); + + signalRService.on(geoHubReconnecting, () => { + retireGeolocationConnection(); set({ isGeolocationHubConnected: false }); }); + // Covers both SignalR's automatic reconnect and the service's own rebuild after onclose: + // either way it is a new connection id outside the department group. + signalRService.on(geoHubReconnected, () => { + retireGeolocationConnection(); + set({ isGeolocationHubConnected: false }); + void joinGeolocationGroup(set).catch(() => { + // runGeolocationJoin already logged and scheduled its retry. + }); + }); + signalRService.on('onGeolocationConnect', () => { logger.info({ message: 'Connected to geolocation SignalR hub', @@ -614,6 +749,10 @@ export const useSignalRStore = create((set, get) => ({ hubName: Env.REALTIME_GEO_HUB_NAME, methods: ['onPersonnelLocationUpdated', 'onUnitLocationUpdated', 'onGeolocationConnect'], }); + + // The server only adds a connection to the department group — and only then replies + // onGeolocationConnect — in response to this call. + await joinGeolocationGroup(set); } catch (error) { const err = error instanceof Error ? error : new Error('Unknown error occurred'); // The service already logged the connect failure with hub context; logging @@ -623,6 +762,8 @@ export const useSignalRStore = create((set, get) => ({ }, disconnectGeolocationHub: async () => { try { + // A join still in flight or scheduled must not count or retry after this teardown. + retireGeolocationConnection(); await signalRService.disconnectFromHub(Env.REALTIME_GEO_HUB_NAME); set({ isGeolocationHubConnected: false, lastGeolocationMessage: null }); } catch (error) { diff --git a/src/stores/status/__tests__/store.test.ts b/src/stores/status/__tests__/store.test.ts index c1fd4f97..14fd11ce 100644 --- a/src/stores/status/__tests__/store.test.ts +++ b/src/stores/status/__tests__/store.test.ts @@ -66,6 +66,9 @@ jest.mock('@/stores/calls/store', () => ({ jest.mock('@/services/offline-event-manager.service', () => ({ offlineEventManager: { queueUnitStatusEvent: jest.fn(), + isDeviceOffline: jest.fn(() => false), + hasUndeliveredUnitStatuses: jest.fn(() => false), + deliverQueuedUnitStatuses: jest.fn(async () => true), }, })); jest.mock('@/utils/network', () => ({ @@ -496,4 +499,101 @@ describe('StatusesStore', () => { expect(result.current.isLoading).toBe(false); expect(result.current.error).toBe('Failed to save unit status'); }); + + describe('delivery outcome and ordering', () => { + const buildInput = () => { + const input = new SaveUnitStatusInput(); + input.Id = 'unit1'; + input.Type = '2'; + return input; + }; + + const save = async (input: SaveUnitStatusInput) => { + const { result } = renderHook(() => useStatusesStore()); + let outcome: string | undefined; + await act(async () => { + outcome = await result.current.saveUnitStatus(input); + }); + return { outcome, result }; + }; + + beforeEach(() => { + // Earlier tests leave persistent implementations behind; pin the ones these depend on. + mockSaveUnitStatus.mockReset().mockResolvedValue(new UnitTypeStatusesResult()); + mockOfflineEventManager.queueUnitStatusEvent.mockReset().mockReturnValue('queued-event-id'); + (mockOfflineEventManager.isDeviceOffline as jest.Mock).mockReset().mockReturnValue(false); + (mockOfflineEventManager.hasUndeliveredUnitStatuses as jest.Mock).mockReset().mockReturnValue(false); + (mockOfflineEventManager.deliverQueuedUnitStatuses as jest.Mock).mockReset().mockResolvedValue(true); + mockSetActiveUnitWithFetch.mockResolvedValue(undefined); + }); + + it("reports 'sent' when the status reached the server", async () => { + const { outcome } = await save(buildInput()); + + expect(outcome).toBe('sent'); + expect(mockSaveUnitStatus).toHaveBeenCalledTimes(1); + expect(mockOfflineEventManager.queueUnitStatusEvent).not.toHaveBeenCalled(); + }); + + it("reports 'queued' when a network failure sends the status to the offline queue", async () => { + mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); + + const { outcome } = await save(buildInput()); + + expect(outcome).toBe('queued'); + expect(mockOfflineEventManager.queueUnitStatusEvent).toHaveBeenCalledTimes(1); + }); + + it('queues straight away without waiting on a request when the device has no network', async () => { + (mockOfflineEventManager.isDeviceOffline as jest.Mock).mockReturnValue(true); + + const { outcome, result } = await save(buildInput()); + + expect(outcome).toBe('queued'); + expect(mockSaveUnitStatus).not.toHaveBeenCalled(); + const queued = mockOfflineEventManager.queueUnitStatusEvent.mock.calls[0]; + expect(queued[0]).toBe('unit1'); + expect(queued[1]).toBe('2'); + expect(queued[7]).toBeInstanceOf(Date); + expect(result.current.isLoading).toBe(false); + }); + + it('delivers older queued statuses for the unit before sending the new one', async () => { + const calls: string[] = []; + (mockOfflineEventManager.hasUndeliveredUnitStatuses as jest.Mock).mockReturnValue(true); + (mockOfflineEventManager.deliverQueuedUnitStatuses as jest.Mock).mockImplementation(async () => { + calls.push('deliver-queued'); + return true; + }); + mockSaveUnitStatus.mockImplementation(async () => { + calls.push('save-new'); + return new UnitTypeStatusesResult(); + }); + + const { outcome } = await save(buildInput()); + + expect(outcome).toBe('sent'); + expect(mockOfflineEventManager.hasUndeliveredUnitStatuses).toHaveBeenCalledWith('unit1'); + expect(mockOfflineEventManager.deliverQueuedUnitStatuses).toHaveBeenCalledWith('unit1'); + expect(calls).toEqual(['deliver-queued', 'save-new']); + }); + + it('queues the new status behind older ones that still cannot be delivered', async () => { + (mockOfflineEventManager.hasUndeliveredUnitStatuses as jest.Mock).mockReturnValue(true); + (mockOfflineEventManager.deliverQueuedUnitStatuses as jest.Mock).mockResolvedValue(false); + + const { outcome } = await save(buildInput()); + + // Sending it directly would let the older status replay afterwards and become current again. + expect(outcome).toBe('queued'); + expect(mockSaveUnitStatus).not.toHaveBeenCalled(); + expect(mockOfflineEventManager.queueUnitStatusEvent).toHaveBeenCalledTimes(1); + }); + + it('does not try to deliver the queue when nothing is waiting for this unit', async () => { + await save(buildInput()); + + expect(mockOfflineEventManager.deliverQueuedUnitStatuses).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/stores/status/store.ts b/src/stores/status/store.ts index 2ac11787..714f76da 100644 --- a/src/stores/status/store.ts +++ b/src/stores/status/store.ts @@ -191,12 +191,64 @@ export const useStatusBottomSheetStore = create((set, ge }), })); +/** 'sent' reached the server; 'queued' is held on the device and replayed by the offline queue. */ +export type SaveUnitStatusOutcome = 'sent' | 'queued'; + interface StatusesState { isLoading: boolean; error: string | null; - saveUnitStatus: (input: SaveUnitStatusInput) => Promise; + saveUnitStatus: (input: SaveUnitStatusInput) => Promise; } +const queueUnitStatus = (input: SaveUnitStatusInput, recordedAt: Date): string => { + const roles = + input.Roles?.map((role: SaveUnitStatusRoleInput) => ({ + roleId: role.RoleId, + userId: role.UserId, + })) ?? []; + + let gpsData: + | { + latitude?: string; + longitude?: string; + accuracy?: string; + altitude?: string; + altitudeAccuracy?: string; + speed?: string; + heading?: string; + } + | undefined; + + if (input.Latitude && input.Longitude) { + gpsData = { + latitude: input.Latitude, + longitude: input.Longitude, + accuracy: input.Accuracy, + altitude: input.Altitude, + altitudeAccuracy: input.AltitudeAccuracy, + speed: input.Speed, + heading: input.Heading, + }; + } else { + const locationState = useLocationStore.getState(); + if (locationState.latitude !== null && locationState.longitude !== null) { + gpsData = { + latitude: locationState.latitude.toString(), + longitude: locationState.longitude.toString(), + accuracy: locationState.accuracy?.toString(), + altitude: locationState.altitude?.toString(), + altitudeAccuracy: undefined, + speed: locationState.speed?.toString(), + heading: locationState.heading?.toString(), + }; + } + } + + // Pass the original status time: a failed request can take up to the client timeout, and + // stamping at queue time would shift the status by that much. + return offlineEventManager.queueUnitStatusEvent(input.Id, input.Type, input.Note || '', input.RespondingTo || '', input.RespondingToType, roles, gpsData, recordedAt); +}; + export const useStatusesStore = create((set) => ({ isLoading: false, error: null, @@ -232,6 +284,39 @@ export const useStatusesStore = create((set) => ({ } } + // No network interface at all: the request can only fail, and there is no reason to hold the + // crew on a spinner while it does. Queue it straight away. + if (offlineEventManager.isDeviceOffline()) { + const eventId = queueUnitStatus(input, date); + + logger.info({ + message: 'Device offline, unit status queued without attempting a direct save', + context: { unitId: input.Id, statusType: input.Type, eventId }, + }); + + set({ isLoading: false }); + return 'queued'; + } + + // The server treats the most recently inserted state as current. An older status still in the + // queue would replay after this one and revert the unit to it, so it has to go first — and if + // it cannot, this one waits behind it in the queue. + if (offlineEventManager.hasUndeliveredUnitStatuses(input.Id)) { + const caughtUp = await offlineEventManager.deliverQueuedUnitStatuses(input.Id); + + if (!caughtUp) { + const eventId = queueUnitStatus(input, date); + + logger.info({ + message: 'Earlier unit statuses still undelivered, queued this one behind them', + context: { unitId: input.Id, statusType: input.Type, eventId }, + }); + + set({ isLoading: false }); + return 'queued'; + } + } + try { await saveUnitStatus(input); @@ -254,6 +339,8 @@ export const useStatusesStore = create((set) => ({ }); } } + + return 'sent'; } catch (error) { // Only queue the status when the failure is genuinely network-related // (offline/timeout, no response). Server rejections (400 validation, @@ -273,52 +360,7 @@ export const useStatusesStore = create((set) => ({ context: { unitId: input.Id, statusType: input.Type, error }, }); - const roles = - input.Roles?.map((role) => ({ - roleId: role.RoleId, - userId: role.UserId, - })) ?? []; - - let gpsData: - | { - latitude?: string; - longitude?: string; - accuracy?: string; - altitude?: string; - altitudeAccuracy?: string; - speed?: string; - heading?: string; - } - | undefined; - - if (input.Latitude && input.Longitude) { - gpsData = { - latitude: input.Latitude, - longitude: input.Longitude, - accuracy: input.Accuracy, - altitude: input.Altitude, - altitudeAccuracy: input.AltitudeAccuracy, - speed: input.Speed, - heading: input.Heading, - }; - } else { - const locationState = useLocationStore.getState(); - if (locationState.latitude !== null && locationState.longitude !== null) { - gpsData = { - latitude: locationState.latitude.toString(), - longitude: locationState.longitude.toString(), - accuracy: locationState.accuracy?.toString(), - altitude: locationState.altitude?.toString(), - altitudeAccuracy: undefined, - speed: locationState.speed?.toString(), - heading: locationState.heading?.toString(), - }; - } - } - - // Pass the original status time: the failed request above can take up to the client - // timeout, and stamping at queue time would shift the status by that much. - const eventId = offlineEventManager.queueUnitStatusEvent(input.Id, input.Type, input.Note || '', input.RespondingTo || '', input.RespondingToType, roles, gpsData, date); + const eventId = queueUnitStatus(input, date); logger.info({ message: 'Unit status queued for offline processing', @@ -326,6 +368,7 @@ export const useStatusesStore = create((set) => ({ }); set({ isLoading: false }); + return 'queued'; } } catch (error) { logger.error({ diff --git a/src/stores/toast/store.ts b/src/stores/toast/store.ts index f58856eb..731b9372 100644 --- a/src/stores/toast/store.ts +++ b/src/stores/toast/store.ts @@ -11,12 +11,21 @@ interface ToastMessage { interface ToastStore { toasts: ToastMessage[]; + /** + * Toast hosts mounted inside native Modal windows, in the order they opened. A native Modal is its + * own window, so toasts drawn in the app window sit underneath it; while any of these exist the + * toasts render in the most recently opened one instead of the app window. + */ + modalHosts: string[]; showToast: (type: ToastType, message: string, title?: string) => void; removeToast: (id: string) => void; + registerModalHost: (hostId: string) => void; + unregisterModalHost: (hostId: string) => void; } export const useToastStore = create((set) => ({ toasts: [], + modalHosts: [], showToast: (type, message, title) => { const id = Math.random().toString(36).substring(7); set((state) => ({ @@ -34,4 +43,12 @@ export const useToastStore = create((set) => ({ toasts: state.toasts.filter((toast) => toast.id !== id), })); }, + registerModalHost: (hostId) => { + set((state) => (state.modalHosts.includes(hostId) ? state : { modalHosts: [...state.modalHosts, hostId] })); + }, + unregisterModalHost: (hostId) => { + set((state) => ({ + modalHosts: state.modalHosts.filter((id) => id !== hostId), + })); + }, })); diff --git a/src/translations/ar.json b/src/translations/ar.json index e658691b..5bf52b79 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -716,6 +716,7 @@ "address": "العنوان", "bluesky": "Bluesky", "cancel": "إلغاء", + "categoryLabel": "الفئة: {{name}}", "cellPhone": "الهاتف الخلوي", "city": "المدينة", "cityState": "المدينة والولاية", @@ -729,6 +730,7 @@ "contactNotesLoading": "جاري تحميل ملاحظات جهة الاتصال...", "contactType": "نوع جهة الاتصال", "countryId": "معرف البلد", + "customFields": "حقول مخصصة", "delete": "حذف", "deleteConfirm": "هل أنت متأكد أنك تريد حذف جهة الاتصال هذه؟", "deleteSuccess": "تم حذف جهة الاتصال بنجاح", @@ -753,6 +755,7 @@ "share_unavailable": "المشاركة غير متاحة على هذا الجهاز." }, "formError": "يرجى إصلاح الأخطاء في النموذج", + "hasGeofence": "لهذا الموقع سياج جغرافي مرسوم على الخريطة.", "homePhone": "هاتف المنزل", "identification": "التعريف", "important": "وضع علامة كمهم", @@ -762,6 +765,7 @@ "linkedin": "لينكد إن", "locationCoordinates": "إحداثيات الموقع", "locationInformation": "معلومات الموقع", + "mailingAddress": "العنوان البريدي", "mastodon": "ماستودون", "mobile": "جوال", "name": "الاسم", @@ -773,6 +777,7 @@ "otherInfo": "معلومات أخرى", "person": "شخص", "phone": "هاتف", + "physicalAddress": "العنوان الفعلي", "preplan": { "access": "الوصول", "access_notes": "ملاحظات الوصول", @@ -838,13 +843,8 @@ "unknown_person": "شخص غير معروف", "visibility": "الرؤية", "website": "الموقع الإلكتروني", - "zip": "الرمز البريدي", "withheld": "تم إخفاء {{count}} من التفاصيل المحمية.", - "categoryLabel": "الفئة: {{name}}", - "physicalAddress": "العنوان الفعلي", - "mailingAddress": "العنوان البريدي", - "hasGeofence": "لهذا الموقع سياج جغرافي مرسوم على الخريطة.", - "customFields": "حقول مخصصة" + "zip": "الرمز البريدي" }, "data_protection": { "conceal": "إخفاؤها مجددًا", @@ -935,6 +935,81 @@ "send_message": "Message", "tab_title": "القيادة" }, + "inventory": { + "assetStatus": { + "0": "في الخدمة", + "1": "مصروف", + "2": "قيد الإصلاح", + "3": "تالف", + "4": "مفقود", + "5": "مستهلك", + "6": "متقاعد" + }, + "awaitingWitness": "تغير عنصر خاضع للرقابة؛ يجب أن يشهد شخص ثانٍ على هذا الجرد عبر الويب.", + "back": "رجوع", + "cancel": "إلغاء", + "cancelBody": "لن يُسجل أي شيء تم جرده حتى الآن.", + "cancelCount": "إلغاء الجرد", + "cancelTitle": "إلغاء هذا الجرد؟", + "cannotCount": "يتطلب الجرد صلاحية تعديل المخزون في هذه الوحدة.", + "complete": "إكمال", + "completeBody": "تختلف {{count}} عناصر عن المتوقع. سيؤدي الإكمال إلى تسجيل الفروقات.", + "completeNoChanges": "كل شيء مطابق للمتوقع.", + "completeTitle": "إكمال الجرد؟", + "completed": "اكتمل الجرد.", + "count": "جرد المخزون", + "countName": "فحص {{name}} {{date}}", + "countStatus": { + "0": "قيد التنفيذ", + "1": "بانتظار شاهد", + "2": "مكتمل", + "3": "ملغى" + }, + "counts": "عمليات الجرد الأخيرة", + "differences": "{{count}} فروقات", + "disabled": "المخزون متوقف لإدارتك.", + "empty": "لا شيء هنا.", + "errors": { + "CountAlreadyExists": "هذا الجرد موجود بالفعل.", + "CountEmpty": "لا يوجد ما يُجرد هنا.", + "CountScopeTooLarge": "عناصر كثيرة جدًا لجرد واحد. اجرد حجرة واحدة في كل مرة.", + "CountSnapshotChanged": "تغير المخزون في هذه الوحدة أثناء الجرد. ألغِ هذا الجرد وابدأ من جديد.", + "CountStateConflict": "تغير هذا الجرد على جهاز آخر. افتحه مجددًا وحاول مرة أخرى.", + "InvalidCount": "تحقق من القيم المجرودة وحاول مرة أخرى.", + "InventoryDisabled": "المخزون متوقف لإدارتك.", + "LocationUnavailable": "هذا الموقع غير متاح.", + "MembershipRequired": "لست عضوًا نشطًا في هذه الإدارة.", + "MigrationRequired": "يجري إعداد المخزون لإدارتك.", + "PermissionRequired": "ليست لديك صلاحية للقيام بذلك.", + "ProtectedDataRequired": "افتح البيانات المحمية للمتابعة.", + "ReturnAssetFirst": "عنصر معلّم كمفقود لا يزال مصروفًا لشخص ما. أرجعه أولًا عبر الويب.", + "locked": "افتح البيانات المحمية للمتابعة.", + "retry": "تعذر الوصول إلى الخادم. حاول مرة أخرى." + }, + "expected": "المتوقع {{quantity}} {{unit}}", + "keep": "متابعة الجرد", + "less": "واحد أقل", + "matches": "مطابق", + "missing": "مفقود", + "more": "واحد أكثر", + "noLocations": "لا يوجد مخزون مخصص لهذه الوحدة.", + "notMigrated": "يجري إعداد المخزون لإدارتك.", + "pickLocation": "أين يتم الجرد", + "present": "موجود", + "progress": "تم جرد {{counted}} من {{total}}", + "quantity": "× {{quantity}}", + "save": "حفظ التقدم", + "selectUnit": "اختر وحدتك أولًا.", + "serial": "الرقم التسلسلي {{serial}}", + "start": "بدء الجرد", + "startCount": "جرد المخزون", + "startHelp": "اجرد ما هو موجود فعلًا في الوحدة. تُسجل الفروقات عند إكمال الجرد.", + "title": "المخزون", + "titleFor": "المخزون · {{name}}", + "unnamedItem": "عنصر", + "unnamedLocation": "حجرة", + "wholeUnit": "الوحدة بالكامل: {{name}}" + }, "livekit": { "audio_devices": "أجهزة الصوت", "audio_settings": "إعدادات الصوت", @@ -1064,14 +1139,17 @@ "title": "الملاحظات" }, "notifications": { + "additional_info": "معلومات إضافية", "confirm_delete_message_count": "هل أنت متأكد من حذف {{count}} إشعارات؟ لا يمكن التراجع عن هذا الإجراء.", "confirm_delete_message_one": "هل أنت متأكد من حذف هذا الإشعار؟ لا يمكن التراجع عن هذا الإجراء.", "confirm_delete_title": "تأكيد الحذف", "delete_selected": "حذف الإشعارات المحددة", "deselect_all": "إلغاء تحديد الكل", + "detail_title": "إشعار", "empty": "لا توجد تحديثات", "enter_selection_mode": "تحديد الإشعارات", "open": "فتح الإشعارات", + "open_reference": "فتح", "remove_failed_count": "فشل حذف الإشعارات", "remove_failed_one": "فشل حذف الإشعار", "removed_count": "تم حذف {{count}} إشعارات", @@ -1080,11 +1158,8 @@ "selected_count": "تم تحديد {{count}}", "title": "الإشعارات", "unable_to_load": "تعذر تحميل الإشعارات", - "detail_title": "إشعار", - "additional_info": "معلومات إضافية", "view_call": "عرض المكالمة", - "view_chat": "عرض المحادثة", - "open_reference": "فتح" + "view_chat": "عرض المحادثة" }, "onboarding": { "message": "مرحبًا بك في تطبيق Resgrid Unit" @@ -1092,6 +1167,7 @@ "operations": { "all": "الكل", "back": "رجوع", + "crewOf": "طاقم {{names}}", "disabled": "عمليات النشر غير مفعّلة لهذا القسم.", "entryType": { "0": "نشر", @@ -1100,18 +1176,49 @@ }, "errors": { "denied": "ليس لديك صلاحية الوصول إلى هذا النشر.", + "deployments_attachment_too_large": "الإيصال كبير جدًا.", + "deployments_closed": "عملية النشر هذه مغلقة.", "disabled": "هذه الميزة معطّلة لقسمك.", + "expenses_receipt_invalid": "تعذرت قراءة الإيصال.", "locked": "افتح البيانات المحمية للمتابعة.", "retry": "تعذر الوصول إلى الخادم. حاول مرة أخرى.", - "validation": "رفض الخادم التغيير.", - "timereports_subject_covered": "لهذا الطاقم أو الشخص وقت مسجل بالفعل في تقرير آخر لهذا اليوم.", "timereports_date_exists": "يوجد بالفعل تقرير لهذا الطاقم أو الشخص في ذلك اليوم.", - "timereports_scope_invalid": "هذا الطاقم أو الشخص غير نشط في عملية النشر هذه.", "timereports_locked": "لم يعد بالإمكان تغيير هذا التقرير.", + "timereports_scope_invalid": "هذا الطاقم أو الشخص غير نشط في عملية النشر هذه.", + "timereports_subject_covered": "لهذا الطاقم أو الشخص وقت مسجل بالفعل في تقرير آخر لهذا اليوم.", "timereports_time_invalid": "أدخل الأوقات بصيغة HH:mm.", - "deployments_closed": "عملية النشر هذه مغلقة.", - "expenses_receipt_invalid": "تعذرت قراءة الإيصال.", - "deployments_attachment_too_large": "الإيصال كبير جدًا." + "validation": "رفض الخادم التغيير." + }, + "expenses": { + "add": "مصروف جديد", + "amount": "المبلغ ({{currency}})", + "amountInvalid": "أدخل مبلغًا مثل 42.50.", + "cancel": "إلغاء", + "choosePhoto": "اختيار صورة", + "city": "المدينة", + "description": "الوصف", + "hasReceipt": "إيصال", + "noneToday": "لا توجد مصروفات لهذا اليوم.", + "otherDays": "أيام أخرى", + "photoDenied": "اسمح بالوصول إلى الكاميرا أو الصور لإرفاق إيصال.", + "receiptAttached": "تم إرفاق صورة الإيصال.", + "remove": "إزالة", + "removeBody": "سيؤدي هذا إلى إزالة المصروف وإيصاله من عملية النشر.", + "removeTitle": "إزالة المصروف؟", + "save": "إضافة مصروف", + "saved": "تم حفظ المصروف.", + "takePhoto": "التقاط صورة", + "title": "المصروفات", + "type": { + "0": "بدل الوجبات اليومي", + "1": "السكن", + "2": "سكن خاص", + "3": "العبّارة", + "4": "الوقود", + "5": "إعادة تزويد المستلزمات", + "6": "أخرى" + }, + "typeLabel": "نوع المصروف" }, "financeMode": { "0": "تشغيلي", @@ -1119,18 +1226,18 @@ "2": "قابل للفوترة" }, "issues": { + "break_rule": "لا توجد استراحة مسجلة لمناوبة طويلة.", "end_before_start": "يجب أن يكون وقت الانتهاء بعد وقت البدء.", + "entries_overlap": "يتداخل قيدان للشخص أو الوحدة نفسها.", + "long_travel": "وقت السفر أكثر من المسموح في يوم واحد ويحتاج إلى اعتماد.", "missing_subject": "اختر لمن أو لماذا هذا الإدخال.", "missing_time": "أدخل وقت البدء والانتهاء.", + "no_entries": "أضف قيدًا واحدًا على الأقل.", + "outside_report_date": "هذا القيد يقع خارج تاريخ التقرير.", "overlap": "يتداخل هذا الإدخال مع إدخال آخر لنفس الشخص أو الوحدة.", - "subject_outside_report_scope": "هذا القيد يخص شخصًا خارج طاقم هذا التقرير أو الشخص المعني.", - "subject_on_other_report": "لهذا الشخص أو الوحدة وقت مسجل بالفعل في تقرير آخر لنفس اليوم.", "subject_not_on_roster": "هذا القيد يخص شخصًا غير موجود في القائمة.", - "entries_overlap": "يتداخل قيدان للشخص أو الوحدة نفسها.", - "no_entries": "أضف قيدًا واحدًا على الأقل.", - "break_rule": "لا توجد استراحة مسجلة لمناوبة طويلة.", - "long_travel": "وقت السفر أكثر من المسموح في يوم واحد ويحتاج إلى اعتماد.", - "outside_report_date": "هذا القيد يقع خارج تاريخ التقرير." + "subject_on_other_report": "لهذا الشخص أو الوحدة وقت مسجل بالفعل في تقرير آخر لنفس اليوم.", + "subject_outside_report_scope": "هذا القيد يخص شخصًا خارج طاقم هذا التقرير أو الشخص المعني." }, "mars": { "counts": "{{errors}} أخطاء · {{warnings}} تحذيرات", @@ -1144,6 +1251,7 @@ "title": "F-42", "validate": "تحقق" }, + "nextDay": "اليوم التالي", "noNumbers": "لا توجد أرقام حادث أو طلب", "none": "لا توجد عمليات نشر.", "open": "مفتوحة", @@ -1153,6 +1261,7 @@ "2": "حادث", "3": "عودة" }, + "previousDay": "اليوم السابق", "reportStatus": { "0": "مسودة", "1": "مُقدَّم", @@ -1161,6 +1270,22 @@ "4": "ملغى" }, "roster": "{{units}} وحدات · {{personnel}} أفراد", + "scope": { + "crew": "الطاقم: {{name}}", + "deployment": "عملية النشر بالكامل", + "individual": "{{name}}", + "mine": "وقتي" + }, + "sign": { + "asCrewBoss": "التوقيع كقائد للطاقم", + "crewBossPending": "لم يوقّع قائد الطاقم بعد.", + "crewBossSigned": "وقّع قائد الطاقم {{when}}", + "customerName": "اسم موقّع الجهة", + "customerPending": "لم يُسجل توقيع الجهة.", + "customerSigned": "موقّع الجهة: {{name}}", + "recordCustomer": "تسجيل توقيع الجهة", + "title": "التوقيعات" + }, "status": { "0": "مخطط", "1": "استعداد", @@ -1172,35 +1297,35 @@ "time": { "accommodation": "الإقامة مقدمة من الجهة", "addEntry": "إضافة إدخال", + "applyToCrew": "تطبيق على الطاقم", + "approve": "اعتماد التقرير", + "awaitingApproval": "بانتظار الاعتماد ({{count}})", + "coveredBy": "هذا الوقت مسجل بالفعل في التقرير #{{number}} لهذا اليوم.", + "crewReport": "تقرير وقت الطاقم #{{number}} · {{name}}", + "dayReports": "تقارير هذا اليوم", "from": "من", "hours": "{{hours}} س", + "individualReport": "تقرير الوقت #{{number}} · {{name}}", "meals": "الوجبات مقدمة من الجهة", + "nextDay": "ينتهي في اليوم التالي", + "noCrewReport": "لا يوجد تقرير وقت طاقم لـ {{name}} في هذا اليوم.", "noEntries": "لا توجد إدخالات بعد.", "noReport": "لا يوجد تقرير وقت لهذا اليوم.", + "notOnRoster": "أنت لست ضمن قائمة أفراد أو طاقم عملية النشر هذه.", "notes": "ملاحظات", + "readOnly": "يمكنك عرض هذا التقرير لكن لا يمكنك تغييره.", "remove": "إزالة", "report": "التقرير #{{number}}", "save": "حفظ الإدخالات", "start": "بدء تقرير الوقت", + "startCrew": "بدء تقرير وقت الطاقم", "subject": "من أو ماذا", "submit": "تقديم التقرير", "title": "الوقت", "to": "إلى", "total": "الإجمالي {{hours}} س", "type": "نوع الإدخال", - "unpaidBreak": "استراحة غير مدفوعة (دقيقة)", - "crewReport": "تقرير وقت الطاقم #{{number}} · {{name}}", - "individualReport": "تقرير الوقت #{{number}} · {{name}}", - "noCrewReport": "لا يوجد تقرير وقت طاقم لـ {{name}} في هذا اليوم.", - "coveredBy": "هذا الوقت مسجل بالفعل في التقرير #{{number}} لهذا اليوم.", - "startCrew": "بدء تقرير وقت الطاقم", - "readOnly": "يمكنك عرض هذا التقرير لكن لا يمكنك تغييره.", - "nextDay": "ينتهي في اليوم التالي", - "applyToCrew": "تطبيق على الطاقم", - "approve": "اعتماد التقرير", - "dayReports": "تقارير هذا اليوم", - "awaitingApproval": "بانتظار الاعتماد ({{count}})", - "notOnRoster": "أنت لست ضمن قائمة أفراد أو طاقم عملية النشر هذه." + "unpaidBreak": "استراحة غير مدفوعة (دقيقة)" }, "title": "عمليات النشر", "usage": { @@ -1214,56 +1339,6 @@ "startOdometer": "عداد المسافة الابتدائي", "title": "الاستخدام", "unit": "الوحدة" - }, - "crewOf": "طاقم {{names}}", - "previousDay": "اليوم السابق", - "nextDay": "اليوم التالي", - "scope": { - "deployment": "عملية النشر بالكامل", - "mine": "وقتي", - "crew": "الطاقم: {{name}}", - "individual": "{{name}}" - }, - "sign": { - "title": "التوقيعات", - "crewBossSigned": "وقّع قائد الطاقم {{when}}", - "crewBossPending": "لم يوقّع قائد الطاقم بعد.", - "customerSigned": "موقّع الجهة: {{name}}", - "customerPending": "لم يُسجل توقيع الجهة.", - "asCrewBoss": "التوقيع كقائد للطاقم", - "customerName": "اسم موقّع الجهة", - "recordCustomer": "تسجيل توقيع الجهة" - }, - "expenses": { - "title": "المصروفات", - "type": { - "0": "بدل الوجبات اليومي", - "1": "السكن", - "2": "سكن خاص", - "3": "العبّارة", - "4": "الوقود", - "5": "إعادة تزويد المستلزمات", - "6": "أخرى" - }, - "typeLabel": "نوع المصروف", - "amount": "المبلغ ({{currency}})", - "description": "الوصف", - "city": "المدينة", - "takePhoto": "التقاط صورة", - "choosePhoto": "اختيار صورة", - "receiptAttached": "تم إرفاق صورة الإيصال.", - "save": "إضافة مصروف", - "add": "مصروف جديد", - "noneToday": "لا توجد مصروفات لهذا اليوم.", - "otherDays": "أيام أخرى", - "hasReceipt": "إيصال", - "remove": "إزالة", - "removeTitle": "إزالة المصروف؟", - "removeBody": "سيؤدي هذا إلى إزالة المصروف وإيصاله من عملية النشر.", - "cancel": "إلغاء", - "saved": "تم حفظ المصروف.", - "amountInvalid": "أدخل مبلغًا مثل 42.50.", - "photoDenied": "اسمح بالوصول إلى الكاميرا أو الصور لإرفاق إيصال." } }, "protocols": { @@ -1662,6 +1737,7 @@ "background_location": "الموقع في الخلفية", "contact_us": "اتصل بنا", "current_unit": "الوحدة الحالية", + "custom": "مخصص", "english": "إنجليزي", "enter_password": "أدخل كلمة المرور الخاصة بك", "enter_server_url": "أدخل عنوان URL لواجهة برمجة تطبيقات Resgrid (مثال: https://api.resgrid.com)", @@ -1772,6 +1848,7 @@ "station_destination_enabled": "يمكن الاستجابة للمحطات", "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "المحطات", + "status_queued": "تم حفظ الحالة على هذا الجهاز وسيتم إرسالها تلقائيًا.", "status_saved_successfully": "تم حفظ الحالة بنجاح!" }, "tabs": { @@ -1903,80 +1980,5 @@ "unknown": "غير معروف" } }, - "welcome": "مرحبًا بك في موقع تطبيق Resgrid Unit", - "inventory": { - "title": "المخزون", - "titleFor": "المخزون · {{name}}", - "selectUnit": "اختر وحدتك أولًا.", - "unnamedLocation": "حجرة", - "unnamedItem": "عنصر", - "countName": "فحص {{name}} {{date}}", - "notMigrated": "يجري إعداد المخزون لإدارتك.", - "disabled": "المخزون متوقف لإدارتك.", - "noLocations": "لا يوجد مخزون مخصص لهذه الوحدة.", - "startCount": "جرد المخزون", - "wholeUnit": "الوحدة بالكامل: {{name}}", - "pickLocation": "أين يتم الجرد", - "startHelp": "اجرد ما هو موجود فعلًا في الوحدة. تُسجل الفروقات عند إكمال الجرد.", - "start": "بدء الجرد", - "cannotCount": "يتطلب الجرد صلاحية تعديل المخزون في هذه الوحدة.", - "counts": "عمليات الجرد الأخيرة", - "count": "جرد المخزون", - "empty": "لا شيء هنا.", - "serial": "الرقم التسلسلي {{serial}}", - "quantity": "× {{quantity}}", - "back": "رجوع", - "completeTitle": "إكمال الجرد؟", - "completeBody": "تختلف {{count}} عناصر عن المتوقع. سيؤدي الإكمال إلى تسجيل الفروقات.", - "completeNoChanges": "كل شيء مطابق للمتوقع.", - "cancel": "إلغاء", - "complete": "إكمال", - "cancelTitle": "إلغاء هذا الجرد؟", - "cancelBody": "لن يُسجل أي شيء تم جرده حتى الآن.", - "keep": "متابعة الجرد", - "cancelCount": "إلغاء الجرد", - "progress": "تم جرد {{counted}} من {{total}}", - "differences": "{{count}} فروقات", - "awaitingWitness": "تغير عنصر خاضع للرقابة؛ يجب أن يشهد شخص ثانٍ على هذا الجرد عبر الويب.", - "completed": "اكتمل الجرد.", - "expected": "المتوقع {{quantity}} {{unit}}", - "present": "موجود", - "missing": "مفقود", - "less": "واحد أقل", - "more": "واحد أكثر", - "matches": "مطابق", - "save": "حفظ التقدم", - "countStatus": { - "0": "قيد التنفيذ", - "1": "بانتظار شاهد", - "2": "مكتمل", - "3": "ملغى" - }, - "assetStatus": { - "0": "في الخدمة", - "1": "مصروف", - "2": "قيد الإصلاح", - "3": "تالف", - "4": "مفقود", - "5": "مستهلك", - "6": "متقاعد" - }, - "errors": { - "retry": "تعذر الوصول إلى الخادم. حاول مرة أخرى.", - "locked": "افتح البيانات المحمية للمتابعة.", - "PermissionRequired": "ليست لديك صلاحية للقيام بذلك.", - "MembershipRequired": "لست عضوًا نشطًا في هذه الإدارة.", - "InventoryDisabled": "المخزون متوقف لإدارتك.", - "MigrationRequired": "يجري إعداد المخزون لإدارتك.", - "CountScopeTooLarge": "عناصر كثيرة جدًا لجرد واحد. اجرد حجرة واحدة في كل مرة.", - "CountEmpty": "لا يوجد ما يُجرد هنا.", - "CountSnapshotChanged": "تغير المخزون في هذه الوحدة أثناء الجرد. ألغِ هذا الجرد وابدأ من جديد.", - "CountStateConflict": "تغير هذا الجرد على جهاز آخر. افتحه مجددًا وحاول مرة أخرى.", - "ReturnAssetFirst": "عنصر معلّم كمفقود لا يزال مصروفًا لشخص ما. أرجعه أولًا عبر الويب.", - "ProtectedDataRequired": "افتح البيانات المحمية للمتابعة.", - "CountAlreadyExists": "هذا الجرد موجود بالفعل.", - "LocationUnavailable": "هذا الموقع غير متاح.", - "InvalidCount": "تحقق من القيم المجرودة وحاول مرة أخرى." - } - } + "welcome": "مرحبًا بك في موقع تطبيق Resgrid Unit" } diff --git a/src/translations/de.json b/src/translations/de.json index 7446c369..7826f909 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -716,6 +716,7 @@ "address": "Adresse", "bluesky": "Bluesky", "cancel": "Abbrechen", + "categoryLabel": "Kategorie: {{name}}", "cellPhone": "Mobiltelefon", "city": "Stadt", "cityState": "Stadt und Bundesland", @@ -729,6 +730,7 @@ "contactNotesLoading": "Kontaktnotizen werden geladen...", "contactType": "Kontakttyp", "countryId": "Länder-ID", + "customFields": "Benutzerdefinierte Felder", "delete": "Löschen", "deleteConfirm": "Sind Sie sicher, dass Sie diesen Kontakt löschen möchten?", "deleteSuccess": "Kontakt erfolgreich gelöscht", @@ -753,6 +755,7 @@ "share_unavailable": "Teilen ist auf diesem Gerät nicht verfügbar." }, "formError": "Bitte Fehler im Formular beheben", + "hasGeofence": "Für diesen Ort ist ein Geofence hinterlegt.", "homePhone": "Heimtelefon", "identification": "Identifikation", "important": "Als wichtig markieren", @@ -762,6 +765,7 @@ "linkedin": "LinkedIn", "locationCoordinates": "Standortkoordinaten", "locationInformation": "Standortinformationen", + "mailingAddress": "Postanschrift", "mastodon": "Mastodon", "mobile": "Mobil", "name": "Name", @@ -773,6 +777,7 @@ "otherInfo": "Sonstige Informationen", "person": "Person", "phone": "Telefon", + "physicalAddress": "Anschrift", "preplan": { "access": "Zugang", "access_notes": "Zugangshinweise", @@ -838,13 +843,8 @@ "unknown_person": "Unbekannte Person", "visibility": "Sichtbarkeit", "website": "Website", - "zip": "Postleitzahl", "withheld": "{{count}} geschützte Angaben sind ausgeblendet.", - "categoryLabel": "Kategorie: {{name}}", - "physicalAddress": "Anschrift", - "mailingAddress": "Postanschrift", - "hasGeofence": "Für diesen Ort ist ein Geofence hinterlegt.", - "customFields": "Benutzerdefinierte Felder" + "zip": "Postleitzahl" }, "data_protection": { "conceal": "Wieder ausblenden", @@ -935,6 +935,81 @@ "send_message": "Message", "tab_title": "Führung" }, + "inventory": { + "assetStatus": { + "0": "Im Dienst", + "1": "Ausgegeben", + "2": "In Reparatur", + "3": "Beschädigt", + "4": "Verloren", + "5": "Verbraucht", + "6": "Ausgemustert" + }, + "awaitingWitness": "Ein kontrollierter Artikel hat sich geändert; eine zweite Person muss diese Zählung im Web bezeugen.", + "back": "Zurück", + "cancel": "Abbrechen", + "cancelBody": "Bisher Gezähltes wird nicht gebucht.", + "cancelCount": "Zählung abbrechen", + "cancelTitle": "Diese Zählung abbrechen?", + "cannotCount": "Zum Zählen ist die Berechtigung „Bestand anpassen“ für dieses Fahrzeug nötig.", + "complete": "Abschließen", + "completeBody": "{{count}} Artikel weichen vom Soll ab. Beim Abschließen werden die Abweichungen gebucht.", + "completeNoChanges": "Alles entspricht dem Soll.", + "completeTitle": "Zählung abschließen?", + "completed": "Zählung abgeschlossen.", + "count": "Bestandszählung", + "countName": "Kontrolle {{name}} {{date}}", + "countStatus": { + "0": "In Arbeit", + "1": "Wartet auf Zeugen", + "2": "Abgeschlossen", + "3": "Abgebrochen" + }, + "counts": "Letzte Zählungen", + "differences": "{{count}} Abweichungen", + "disabled": "Der Bestand ist für Ihre Organisation ausgeschaltet.", + "empty": "Hier ist nichts.", + "errors": { + "CountAlreadyExists": "Diese Zählung gibt es bereits.", + "CountEmpty": "Hier gibt es nichts zu zählen.", + "CountScopeTooLarge": "Zu viele Artikel für eine Zählung. Zählen Sie einen Geräteraum nach dem anderen.", + "CountSnapshotChanged": "Der Bestand dieses Fahrzeugs hat sich während der Zählung geändert. Brechen Sie ab und beginnen Sie neu.", + "CountStateConflict": "Diese Zählung wurde auf einem anderen Gerät geändert. Öffnen Sie sie erneut.", + "InvalidCount": "Prüfen Sie die gezählten Werte und versuchen Sie es erneut.", + "InventoryDisabled": "Der Bestand ist für Ihre Organisation ausgeschaltet.", + "LocationUnavailable": "Dieser Ort ist nicht verfügbar.", + "MembershipRequired": "Sie sind kein aktives Mitglied dieser Organisation.", + "MigrationRequired": "Der Bestand wird für Ihre Organisation eingerichtet.", + "PermissionRequired": "Dazu fehlt Ihnen die Berechtigung.", + "ProtectedDataRequired": "Entsperren Sie geschützte Daten, um fortzufahren.", + "ReturnAssetFirst": "Ein als fehlend markierter Artikel ist noch jemandem ausgegeben. Nehmen Sie ihn zuerst im Web zurück.", + "locked": "Entsperren Sie geschützte Daten, um fortzufahren.", + "retry": "Server nicht erreichbar. Versuchen Sie es erneut." + }, + "expected": "Soll {{quantity}} {{unit}}", + "keep": "Weiter zählen", + "less": "Eins weniger", + "matches": "Stimmt", + "missing": "Fehlt", + "more": "Eins mehr", + "noLocations": "Diesem Fahrzeug ist kein Bestand zugeordnet.", + "notMigrated": "Der Bestand wird für Ihre Organisation eingerichtet.", + "pickLocation": "Wo gezählt wird", + "present": "Vorhanden", + "progress": "{{counted}} von {{total}} gezählt", + "quantity": "× {{quantity}}", + "save": "Zwischenstand speichern", + "selectUnit": "Wählen Sie zuerst Ihr Fahrzeug.", + "serial": "Seriennr. {{serial}}", + "start": "Zählung starten", + "startCount": "Bestand zählen", + "startHelp": "Zählen Sie, was tatsächlich auf dem Fahrzeug ist. Abweichungen werden beim Abschließen gebucht.", + "title": "Bestand", + "titleFor": "Bestand · {{name}}", + "unnamedItem": "Artikel", + "unnamedLocation": "Geräteraum", + "wholeUnit": "Ganzes Fahrzeug: {{name}}" + }, "livekit": { "audio_devices": "Audiogeräte", "audio_settings": "Audioeinstellungen", @@ -1064,14 +1139,17 @@ "title": "Notizen" }, "notifications": { + "additional_info": "Zusätzliche Informationen", "confirm_delete_message_count": "Möchten Sie {{count}} Benachrichtigungen wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", "confirm_delete_message_one": "Möchten Sie diese Benachrichtigung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", "confirm_delete_title": "Löschen bestätigen", "delete_selected": "Ausgewählte Benachrichtigungen löschen", "deselect_all": "Auswahl aufheben", + "detail_title": "Benachrichtigung", "empty": "Keine Neuigkeiten vorhanden", "enter_selection_mode": "Benachrichtigungen auswählen", "open": "Benachrichtigungen öffnen", + "open_reference": "Öffnen", "remove_failed_count": "Benachrichtigungen konnten nicht gelöscht werden", "remove_failed_one": "Benachrichtigung konnte nicht gelöscht werden", "removed_count": "{{count}} Benachrichtigungen gelöscht", @@ -1080,11 +1158,8 @@ "selected_count": "{{count}} ausgewählt", "title": "Benachrichtigungen", "unable_to_load": "Benachrichtigungen konnten nicht geladen werden", - "detail_title": "Benachrichtigung", - "additional_info": "Zusätzliche Informationen", "view_call": "Anruf anzeigen", - "view_chat": "Chat anzeigen", - "open_reference": "Öffnen" + "view_chat": "Chat anzeigen" }, "onboarding": { "message": "Willkommen bei Resgrid Unit app site" @@ -1092,6 +1167,7 @@ "operations": { "all": "Alle", "back": "Zurück", + "crewOf": "Besatzung von {{names}}", "disabled": "Einsätze sind für diese Abteilung nicht aktiviert.", "entryType": { "0": "Einsatz", @@ -1100,18 +1176,49 @@ }, "errors": { "denied": "Sie haben keinen Zugriff auf diesen Einsatz.", + "deployments_attachment_too_large": "Der Beleg ist zu groß.", + "deployments_closed": "Dieser Einsatz ist abgeschlossen.", "disabled": "Diese Funktion ist für Ihre Abteilung deaktiviert.", + "expenses_receipt_invalid": "Der Beleg konnte nicht gelesen werden.", "locked": "Geschützte Daten entsperren, um fortzufahren.", "retry": "Server nicht erreichbar. Bitte erneut versuchen.", - "validation": "Der Server hat die Änderung abgelehnt.", - "timereports_subject_covered": "Für diese Besatzung oder Person ist an diesem Tag bereits Zeit in einem anderen Bericht erfasst.", "timereports_date_exists": "Für diese Besatzung oder Person gibt es an diesem Tag bereits einen Bericht.", - "timereports_scope_invalid": "Diese Besatzung oder Person ist in diesem Einsatz nicht aktiv.", "timereports_locked": "Dieser Bericht kann nicht mehr geändert werden.", + "timereports_scope_invalid": "Diese Besatzung oder Person ist in diesem Einsatz nicht aktiv.", + "timereports_subject_covered": "Für diese Besatzung oder Person ist an diesem Tag bereits Zeit in einem anderen Bericht erfasst.", "timereports_time_invalid": "Geben Sie Zeiten als HH:mm ein.", - "deployments_closed": "Dieser Einsatz ist abgeschlossen.", - "expenses_receipt_invalid": "Der Beleg konnte nicht gelesen werden.", - "deployments_attachment_too_large": "Der Beleg ist zu groß." + "validation": "Der Server hat die Änderung abgelehnt." + }, + "expenses": { + "add": "Neue Ausgabe", + "amount": "Betrag ({{currency}})", + "amountInvalid": "Geben Sie einen Betrag wie 42.50 ein.", + "cancel": "Abbrechen", + "choosePhoto": "Foto auswählen", + "city": "Ort", + "description": "Beschreibung", + "hasReceipt": "Beleg", + "noneToday": "Keine Ausgaben an diesem Tag.", + "otherDays": "Andere Tage", + "photoDenied": "Erlauben Sie den Zugriff auf Kamera oder Fotos, um einen Beleg anzuhängen.", + "receiptAttached": "Belegfoto angehängt.", + "remove": "Entfernen", + "removeBody": "Die Ausgabe und ihr Beleg werden aus dem Einsatz entfernt.", + "removeTitle": "Ausgabe entfernen?", + "save": "Ausgabe hinzufügen", + "saved": "Ausgabe gespeichert.", + "takePhoto": "Foto aufnehmen", + "title": "Ausgaben", + "type": { + "0": "Verpflegungspauschale", + "1": "Unterkunft", + "2": "Private Unterkunft", + "3": "Fähre", + "4": "Kraftstoff", + "5": "Materialnachschub", + "6": "Sonstiges" + }, + "typeLabel": "Ausgabenart" }, "financeMode": { "0": "Operativ", @@ -1119,18 +1226,18 @@ "2": "Abrechenbar" }, "issues": { + "break_rule": "Für eine lange Schicht ist keine Pause erfasst.", "end_before_start": "Die Endzeit muss nach der Startzeit liegen.", + "entries_overlap": "Zwei Einträge derselben Person oder Einheit überschneiden sich.", + "long_travel": "Mehr Reisezeit als an einem Tag erlaubt; sie muss genehmigt werden.", "missing_subject": "Wählen Sie, für wen oder was dieser Eintrag gilt.", "missing_time": "Start- und Endzeit eingeben.", + "no_entries": "Fügen Sie mindestens einen Eintrag hinzu.", + "outside_report_date": "Dieser Eintrag liegt außerhalb des Berichtsdatums.", "overlap": "Dieser Eintrag überschneidet sich mit einem anderen für dieselbe Person oder Einheit.", - "subject_outside_report_scope": "Dieser Eintrag betrifft jemanden außerhalb der Besatzung oder Person dieses Berichts.", - "subject_on_other_report": "Für diese Person oder Einheit ist am selben Tag bereits Zeit in einem anderen Bericht erfasst.", "subject_not_on_roster": "Dieser Eintrag betrifft jemanden, der nicht in der Besetzung steht.", - "entries_overlap": "Zwei Einträge derselben Person oder Einheit überschneiden sich.", - "no_entries": "Fügen Sie mindestens einen Eintrag hinzu.", - "break_rule": "Für eine lange Schicht ist keine Pause erfasst.", - "long_travel": "Mehr Reisezeit als an einem Tag erlaubt; sie muss genehmigt werden.", - "outside_report_date": "Dieser Eintrag liegt außerhalb des Berichtsdatums." + "subject_on_other_report": "Für diese Person oder Einheit ist am selben Tag bereits Zeit in einem anderen Bericht erfasst.", + "subject_outside_report_scope": "Dieser Eintrag betrifft jemanden außerhalb der Besatzung oder Person dieses Berichts." }, "mars": { "counts": "{{errors}} Fehler · {{warnings}} Warnungen", @@ -1144,6 +1251,7 @@ "title": "F-42", "validate": "Prüfen" }, + "nextDay": "Nächster Tag", "noNumbers": "Keine Vorfall- oder Anforderungsnummern", "none": "Keine Einsätze.", "open": "Offen", @@ -1153,6 +1261,7 @@ "2": "Einsatz", "3": "Rückkehr" }, + "previousDay": "Vorheriger Tag", "reportStatus": { "0": "Entwurf", "1": "Eingereicht", @@ -1161,6 +1270,22 @@ "4": "Storniert" }, "roster": "{{units}} Einheiten · {{personnel}} Personen", + "scope": { + "crew": "Besatzung: {{name}}", + "deployment": "Gesamter Einsatz", + "individual": "{{name}}", + "mine": "Meine Zeit" + }, + "sign": { + "asCrewBoss": "Als Besatzungsführer unterschreiben", + "crewBossPending": "Noch nicht vom Besatzungsführer unterschrieben.", + "crewBossSigned": "Vom Besatzungsführer unterschrieben {{when}}", + "customerName": "Name des Unterzeichners der Behörde", + "customerPending": "Keine Unterschrift der Behörde erfasst.", + "customerSigned": "Unterzeichner der Behörde: {{name}}", + "recordCustomer": "Unterschrift der Behörde erfassen", + "title": "Unterschriften" + }, "status": { "0": "Geplant", "1": "Bereitschaft", @@ -1172,35 +1297,35 @@ "time": { "accommodation": "Unterkunft von der Behörde gestellt", "addEntry": "Eintrag hinzufügen", + "applyToCrew": "Auf Besatzung übertragen", + "approve": "Bericht genehmigen", + "awaitingApproval": "Wartet auf Genehmigung ({{count}})", + "coveredBy": "Diese Zeit steht an diesem Tag bereits in Bericht #{{number}}.", + "crewReport": "Besatzungszeitbericht #{{number}} · {{name}}", + "dayReports": "Berichte an diesem Tag", "from": "Von", "hours": "{{hours}} h", + "individualReport": "Zeitbericht #{{number}} · {{name}}", "meals": "Verpflegung von der Behörde gestellt", + "nextDay": "Endet am nächsten Tag", + "noCrewReport": "Kein Besatzungszeitbericht für {{name}} an diesem Tag.", "noEntries": "Noch keine Einträge.", "noReport": "Kein Zeitbericht für diesen Tag.", + "notOnRoster": "Sie stehen nicht in der Besetzung oder Besatzung dieses Einsatzes.", "notes": "Notizen", + "readOnly": "Sie können diesen Bericht ansehen, aber nicht ändern.", "remove": "Entfernen", "report": "Bericht #{{number}}", "save": "Einträge speichern", "start": "Zeitbericht beginnen", + "startCrew": "Besatzungszeitbericht beginnen", "subject": "Wer oder was", "submit": "Bericht einreichen", "title": "Zeit", "to": "Bis", "total": "Gesamt {{hours}} h", "type": "Eintragsart", - "unpaidBreak": "Unbezahlte Pause (Min.)", - "crewReport": "Besatzungszeitbericht #{{number}} · {{name}}", - "individualReport": "Zeitbericht #{{number}} · {{name}}", - "noCrewReport": "Kein Besatzungszeitbericht für {{name}} an diesem Tag.", - "coveredBy": "Diese Zeit steht an diesem Tag bereits in Bericht #{{number}}.", - "startCrew": "Besatzungszeitbericht beginnen", - "readOnly": "Sie können diesen Bericht ansehen, aber nicht ändern.", - "nextDay": "Endet am nächsten Tag", - "applyToCrew": "Auf Besatzung übertragen", - "approve": "Bericht genehmigen", - "dayReports": "Berichte an diesem Tag", - "awaitingApproval": "Wartet auf Genehmigung ({{count}})", - "notOnRoster": "Sie stehen nicht in der Besetzung oder Besatzung dieses Einsatzes." + "unpaidBreak": "Unbezahlte Pause (Min.)" }, "title": "Einsätze", "usage": { @@ -1214,56 +1339,6 @@ "startOdometer": "Kilometerstand Start", "title": "Nutzung", "unit": "Einheit" - }, - "crewOf": "Besatzung von {{names}}", - "previousDay": "Vorheriger Tag", - "nextDay": "Nächster Tag", - "scope": { - "deployment": "Gesamter Einsatz", - "mine": "Meine Zeit", - "crew": "Besatzung: {{name}}", - "individual": "{{name}}" - }, - "sign": { - "title": "Unterschriften", - "crewBossSigned": "Vom Besatzungsführer unterschrieben {{when}}", - "crewBossPending": "Noch nicht vom Besatzungsführer unterschrieben.", - "customerSigned": "Unterzeichner der Behörde: {{name}}", - "customerPending": "Keine Unterschrift der Behörde erfasst.", - "asCrewBoss": "Als Besatzungsführer unterschreiben", - "customerName": "Name des Unterzeichners der Behörde", - "recordCustomer": "Unterschrift der Behörde erfassen" - }, - "expenses": { - "title": "Ausgaben", - "type": { - "0": "Verpflegungspauschale", - "1": "Unterkunft", - "2": "Private Unterkunft", - "3": "Fähre", - "4": "Kraftstoff", - "5": "Materialnachschub", - "6": "Sonstiges" - }, - "typeLabel": "Ausgabenart", - "amount": "Betrag ({{currency}})", - "description": "Beschreibung", - "city": "Ort", - "takePhoto": "Foto aufnehmen", - "choosePhoto": "Foto auswählen", - "receiptAttached": "Belegfoto angehängt.", - "save": "Ausgabe hinzufügen", - "add": "Neue Ausgabe", - "noneToday": "Keine Ausgaben an diesem Tag.", - "otherDays": "Andere Tage", - "hasReceipt": "Beleg", - "remove": "Entfernen", - "removeTitle": "Ausgabe entfernen?", - "removeBody": "Die Ausgabe und ihr Beleg werden aus dem Einsatz entfernt.", - "cancel": "Abbrechen", - "saved": "Ausgabe gespeichert.", - "amountInvalid": "Geben Sie einen Betrag wie 42.50 ein.", - "photoDenied": "Erlauben Sie den Zugriff auf Kamera oder Fotos, um einen Beleg anzuhängen." } }, "protocols": { @@ -1662,6 +1737,7 @@ "background_location": "Hintergrundstandort", "contact_us": "Kontaktieren Sie uns", "current_unit": "Aktuelle Einheit", + "custom": "Benutzerdefiniert", "english": "Englisch", "enter_password": "Passwort eingeben", "enter_server_url": "Resgrid API URL eingeben (z. B. https://api.resgrid.com)", @@ -1772,6 +1848,7 @@ "station_destination_enabled": "Kann auf Stationen reagieren", "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Stationen", + "status_queued": "Status auf diesem Gerät gespeichert. Er wird automatisch gesendet.", "status_saved_successfully": "Status erfolgreich gespeichert!" }, "tabs": { @@ -1903,80 +1980,5 @@ "unknown": "Unbekannt" } }, - "welcome": "Willkommen bei Resgrid Unit app site", - "inventory": { - "title": "Bestand", - "titleFor": "Bestand · {{name}}", - "selectUnit": "Wählen Sie zuerst Ihr Fahrzeug.", - "unnamedLocation": "Geräteraum", - "unnamedItem": "Artikel", - "countName": "Kontrolle {{name}} {{date}}", - "notMigrated": "Der Bestand wird für Ihre Organisation eingerichtet.", - "disabled": "Der Bestand ist für Ihre Organisation ausgeschaltet.", - "noLocations": "Diesem Fahrzeug ist kein Bestand zugeordnet.", - "startCount": "Bestand zählen", - "wholeUnit": "Ganzes Fahrzeug: {{name}}", - "pickLocation": "Wo gezählt wird", - "startHelp": "Zählen Sie, was tatsächlich auf dem Fahrzeug ist. Abweichungen werden beim Abschließen gebucht.", - "start": "Zählung starten", - "cannotCount": "Zum Zählen ist die Berechtigung „Bestand anpassen“ für dieses Fahrzeug nötig.", - "counts": "Letzte Zählungen", - "count": "Bestandszählung", - "empty": "Hier ist nichts.", - "serial": "Seriennr. {{serial}}", - "quantity": "× {{quantity}}", - "back": "Zurück", - "completeTitle": "Zählung abschließen?", - "completeBody": "{{count}} Artikel weichen vom Soll ab. Beim Abschließen werden die Abweichungen gebucht.", - "completeNoChanges": "Alles entspricht dem Soll.", - "cancel": "Abbrechen", - "complete": "Abschließen", - "cancelTitle": "Diese Zählung abbrechen?", - "cancelBody": "Bisher Gezähltes wird nicht gebucht.", - "keep": "Weiter zählen", - "cancelCount": "Zählung abbrechen", - "progress": "{{counted}} von {{total}} gezählt", - "differences": "{{count}} Abweichungen", - "awaitingWitness": "Ein kontrollierter Artikel hat sich geändert; eine zweite Person muss diese Zählung im Web bezeugen.", - "completed": "Zählung abgeschlossen.", - "expected": "Soll {{quantity}} {{unit}}", - "present": "Vorhanden", - "missing": "Fehlt", - "less": "Eins weniger", - "more": "Eins mehr", - "matches": "Stimmt", - "save": "Zwischenstand speichern", - "countStatus": { - "0": "In Arbeit", - "1": "Wartet auf Zeugen", - "2": "Abgeschlossen", - "3": "Abgebrochen" - }, - "assetStatus": { - "0": "Im Dienst", - "1": "Ausgegeben", - "2": "In Reparatur", - "3": "Beschädigt", - "4": "Verloren", - "5": "Verbraucht", - "6": "Ausgemustert" - }, - "errors": { - "retry": "Server nicht erreichbar. Versuchen Sie es erneut.", - "locked": "Entsperren Sie geschützte Daten, um fortzufahren.", - "PermissionRequired": "Dazu fehlt Ihnen die Berechtigung.", - "MembershipRequired": "Sie sind kein aktives Mitglied dieser Organisation.", - "InventoryDisabled": "Der Bestand ist für Ihre Organisation ausgeschaltet.", - "MigrationRequired": "Der Bestand wird für Ihre Organisation eingerichtet.", - "CountScopeTooLarge": "Zu viele Artikel für eine Zählung. Zählen Sie einen Geräteraum nach dem anderen.", - "CountEmpty": "Hier gibt es nichts zu zählen.", - "CountSnapshotChanged": "Der Bestand dieses Fahrzeugs hat sich während der Zählung geändert. Brechen Sie ab und beginnen Sie neu.", - "CountStateConflict": "Diese Zählung wurde auf einem anderen Gerät geändert. Öffnen Sie sie erneut.", - "ReturnAssetFirst": "Ein als fehlend markierter Artikel ist noch jemandem ausgegeben. Nehmen Sie ihn zuerst im Web zurück.", - "ProtectedDataRequired": "Entsperren Sie geschützte Daten, um fortzufahren.", - "CountAlreadyExists": "Diese Zählung gibt es bereits.", - "LocationUnavailable": "Dieser Ort ist nicht verfügbar.", - "InvalidCount": "Prüfen Sie die gezählten Werte und versuchen Sie es erneut." - } - } + "welcome": "Willkommen bei Resgrid Unit app site" } diff --git a/src/translations/el.json b/src/translations/el.json index 7c4b81be..d8d7aacf 100644 --- a/src/translations/el.json +++ b/src/translations/el.json @@ -716,6 +716,7 @@ "address": "Διεύθυνση", "bluesky": "Bluesky", "cancel": "Ακύρωση", + "categoryLabel": "Κατηγορία: {{name}}", "cellPhone": "Κινητό Τηλέφωνο", "city": "Πόλη", "cityState": "Πόλη και Νομός", @@ -729,6 +730,7 @@ "contactNotesLoading": "Φόρτωση σημειώσεων επαφής...", "contactType": "Τύπος Επαφής", "countryId": "Αναγνωριστικό Χώρας", + "customFields": "Προσαρμοσμένα πεδία", "delete": "Διαγραφή", "deleteConfirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτή την επαφή;", "deleteSuccess": "Η επαφή διαγράφηκε με επιτυχία", @@ -753,6 +755,7 @@ "share_unavailable": "Η κοινοποίηση δεν είναι διαθέσιμη σε αυτή τη συσκευή." }, "formError": "Διορθώστε τα σφάλματα στη φόρμα", + "hasGeofence": "Αυτή η τοποθεσία έχει χαρτογραφημένη γεωπεριοχή.", "homePhone": "Τηλέφωνο Οικίας", "identification": "Ταυτοποίηση", "important": "Σήμανση ως Σημαντική", @@ -762,6 +765,7 @@ "linkedin": "LinkedIn", "locationCoordinates": "Συντεταγμένες Τοποθεσίας", "locationInformation": "Πληροφορίες Τοποθεσίας", + "mailingAddress": "Ταχυδρομική διεύθυνση", "mastodon": "Mastodon", "mobile": "Κινητό", "name": "Όνομα", @@ -773,6 +777,7 @@ "otherInfo": "Άλλες Πληροφορίες", "person": "Άτομο", "phone": "Τηλέφωνο", + "physicalAddress": "Φυσική διεύθυνση", "preplan": { "access": "Πρόσβαση", "access_notes": "Σημειώσεις πρόσβασης", @@ -838,13 +843,8 @@ "unknown_person": "Άγνωστο άτομο", "visibility": "Ορατότητα", "website": "Ιστότοπος", - "zip": "Ταχυδρομικός Κώδικας", "withheld": "{{count}} προστατευμένα στοιχεία είναι κρυφά.", - "categoryLabel": "Κατηγορία: {{name}}", - "physicalAddress": "Φυσική διεύθυνση", - "mailingAddress": "Ταχυδρομική διεύθυνση", - "hasGeofence": "Αυτή η τοποθεσία έχει χαρτογραφημένη γεωπεριοχή.", - "customFields": "Προσαρμοσμένα πεδία" + "zip": "Ταχυδρομικός Κώδικας" }, "data_protection": { "conceal": "Απόκρυψη ξανά", @@ -935,6 +935,81 @@ "send_message": "Μήνυμα", "tab_title": "Διοίκηση" }, + "inventory": { + "assetStatus": { + "0": "Σε υπηρεσία", + "1": "Διατέθηκε", + "2": "Σε επισκευή", + "3": "Κατεστραμμένο", + "4": "Χαμένο", + "5": "Αναλώθηκε", + "6": "Αποσύρθηκε" + }, + "awaitingWitness": "Άλλαξε ελεγχόμενο είδος· ένα δεύτερο πρόσωπο πρέπει να βεβαιώσει την καταμέτρηση στον ιστό.", + "back": "Πίσω", + "cancel": "Άκυρο", + "cancelBody": "Τίποτα από όσα μετρήθηκαν δεν καταχωρείται.", + "cancelCount": "Ακύρωση καταμέτρησης", + "cancelTitle": "Ακύρωση αυτής της καταμέτρησης;", + "cannotCount": "Η καταμέτρηση απαιτεί το δικαίωμα προσαρμογής αποθέματος σε αυτή τη μονάδα.", + "complete": "Ολοκλήρωση", + "completeBody": "{{count}} είδη διαφέρουν από τα αναμενόμενα. Η ολοκλήρωση καταχωρεί τις διαφορές.", + "completeNoChanges": "Όλα ταιριάζουν με τα αναμενόμενα.", + "completeTitle": "Ολοκλήρωση καταμέτρησης;", + "completed": "Η καταμέτρηση ολοκληρώθηκε.", + "count": "Καταμέτρηση αποθέματος", + "countName": "Έλεγχος {{name}} {{date}}", + "countStatus": { + "0": "Σε εξέλιξη", + "1": "Αναμένει μάρτυρα", + "2": "Ολοκληρώθηκε", + "3": "Ακυρώθηκε" + }, + "counts": "Πρόσφατες καταμετρήσεις", + "differences": "{{count}} διαφορές", + "disabled": "Το απόθεμα είναι απενεργοποιημένο για τον φορέα σας.", + "empty": "Δεν υπάρχει τίποτα εδώ.", + "errors": { + "CountAlreadyExists": "Αυτή η καταμέτρηση υπάρχει ήδη.", + "CountEmpty": "Δεν υπάρχει τίποτα για καταμέτρηση εδώ.", + "CountScopeTooLarge": "Πάρα πολλά είδη για μία καταμέτρηση. Μετρήστε έναν χώρο τη φορά.", + "CountSnapshotChanged": "Το απόθεμα της μονάδας άλλαξε κατά την καταμέτρηση. Ακυρώστε και ξεκινήστε ξανά.", + "CountStateConflict": "Αυτή η καταμέτρηση άλλαξε σε άλλη συσκευή. Ανοίξτε την ξανά.", + "InvalidCount": "Ελέγξτε τις μετρημένες τιμές και δοκιμάστε ξανά.", + "InventoryDisabled": "Το απόθεμα είναι απενεργοποιημένο για τον φορέα σας.", + "LocationUnavailable": "Αυτή η τοποθεσία δεν είναι διαθέσιμη.", + "MembershipRequired": "Δεν είστε ενεργό μέλος αυτού του φορέα.", + "MigrationRequired": "Το απόθεμα ρυθμίζεται για τον φορέα σας.", + "PermissionRequired": "Δεν έχετε δικαίωμα για αυτή την ενέργεια.", + "ProtectedDataRequired": "Ξεκλειδώστε τα προστατευμένα δεδομένα για να συνεχίσετε.", + "ReturnAssetFirst": "Ένα είδος που σημειώθηκε ως χαμένο είναι ακόμη διατεθειμένο σε κάποιον. Επιστρέψτε το πρώτα στον ιστό.", + "locked": "Ξεκλειδώστε τα προστατευμένα δεδομένα για να συνεχίσετε.", + "retry": "Δεν ήταν δυνατή η σύνδεση με τον διακομιστή. Δοκιμάστε ξανά." + }, + "expected": "Αναμενόμενο {{quantity}} {{unit}}", + "keep": "Συνέχεια καταμέτρησης", + "less": "Ένα λιγότερο", + "matches": "Ταιριάζει", + "missing": "Λείπει", + "more": "Ένα περισσότερο", + "noLocations": "Δεν έχει αντιστοιχιστεί απόθεμα σε αυτή τη μονάδα.", + "notMigrated": "Το απόθεμα ρυθμίζεται για τον φορέα σας.", + "pickLocation": "Πού γίνεται η καταμέτρηση", + "present": "Υπάρχει", + "progress": "Μετρήθηκαν {{counted}} από {{total}}", + "quantity": "× {{quantity}}", + "save": "Αποθήκευση προόδου", + "selectUnit": "Επιλέξτε πρώτα τη μονάδα σας.", + "serial": "Σειριακός {{serial}}", + "start": "Έναρξη καταμέτρησης", + "startCount": "Καταμέτρηση αποθέματος", + "startHelp": "Μετρήστε ό,τι υπάρχει πραγματικά στη μονάδα. Οι διαφορές καταχωρούνται με την ολοκλήρωση.", + "title": "Απόθεμα", + "titleFor": "Απόθεμα · {{name}}", + "unnamedItem": "Είδος", + "unnamedLocation": "Χώρος", + "wholeUnit": "Ολόκληρη η μονάδα: {{name}}" + }, "livekit": { "audio_devices": "Συσκευές Ήχου", "audio_settings": "Ρυθμίσεις Ήχου", @@ -1064,14 +1139,17 @@ "title": "Σημειώσεις" }, "notifications": { + "additional_info": "Πρόσθετες Πληροφορίες", "confirm_delete_message_count": "Είστε βέβαιοι ότι θέλετε να διαγράψετε {{count}} ειδοποιήσεις; Η ενέργεια δεν είναι αναστρέψιμη.", "confirm_delete_message_one": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτήν την ειδοποίηση; Η ενέργεια δεν είναι αναστρέψιμη.", "confirm_delete_title": "Επιβεβαίωση διαγραφής", "delete_selected": "Διαγραφή επιλεγμένων ειδοποιήσεων", "deselect_all": "Αποεπιλογή όλων", + "detail_title": "Ειδοποίηση", "empty": "Δεν υπάρχουν ενημερώσεις", "enter_selection_mode": "Επιλογή ειδοποιήσεων", "open": "Άνοιγμα ειδοποιήσεων", + "open_reference": "Άνοιγμα", "remove_failed_count": "Η διαγραφή των ειδοποιήσεων απέτυχε", "remove_failed_one": "Η διαγραφή της ειδοποίησης απέτυχε", "removed_count": "Διαγράφηκαν {{count}} ειδοποιήσεις", @@ -1080,11 +1158,8 @@ "selected_count": "{{count}} επιλεγμένες", "title": "Ειδοποιήσεις", "unable_to_load": "Δεν ήταν δυνατή η φόρτωση των ειδοποιήσεων", - "detail_title": "Ειδοποίηση", - "additional_info": "Πρόσθετες Πληροφορίες", "view_call": "Προβολή Κλήσης", - "view_chat": "Προβολή συνομιλίας", - "open_reference": "Άνοιγμα" + "view_chat": "Προβολή συνομιλίας" }, "onboarding": { "message": "Καλώς ήρθατε στον ιστότοπο της εφαρμογής Resgrid Unit" @@ -1092,6 +1167,7 @@ "operations": { "all": "Όλα", "back": "Πίσω", + "crewOf": "Πλήρωμα του {{names}}", "disabled": "Οι αποστολές δεν είναι ενεργοποιημένες για αυτό το τμήμα.", "entryType": { "0": "Αποστολή", @@ -1100,18 +1176,49 @@ }, "errors": { "denied": "Δεν έχετε πρόσβαση σε αυτήν την αποστολή.", + "deployments_attachment_too_large": "Η απόδειξη είναι πολύ μεγάλη.", + "deployments_closed": "Αυτή η ανάπτυξη έχει κλείσει.", "disabled": "Αυτή η λειτουργία είναι απενεργοποιημένη για το τμήμα σας.", + "expenses_receipt_invalid": "Η απόδειξη δεν μπόρεσε να διαβαστεί.", "locked": "Ξεκλειδώστε τα προστατευμένα δεδομένα για να συνεχίσετε.", "retry": "Δεν ήταν δυνατή η σύνδεση με τον διακομιστή. Δοκιμάστε ξανά.", - "validation": "Ο διακομιστής απέρριψε την αλλαγή.", - "timereports_subject_covered": "Αυτό το πλήρωμα ή πρόσωπο έχει ήδη χρόνο σε άλλη αναφορά για αυτή την ημέρα.", "timereports_date_exists": "Υπάρχει ήδη αναφορά για αυτό το πλήρωμα ή πρόσωπο εκείνη την ημέρα.", - "timereports_scope_invalid": "Αυτό το πλήρωμα ή πρόσωπο δεν είναι ενεργό σε αυτή την ανάπτυξη.", "timereports_locked": "Αυτή η αναφορά δεν μπορεί πλέον να αλλάξει.", + "timereports_scope_invalid": "Αυτό το πλήρωμα ή πρόσωπο δεν είναι ενεργό σε αυτή την ανάπτυξη.", + "timereports_subject_covered": "Αυτό το πλήρωμα ή πρόσωπο έχει ήδη χρόνο σε άλλη αναφορά για αυτή την ημέρα.", "timereports_time_invalid": "Εισαγάγετε ώρες ως HH:mm.", - "deployments_closed": "Αυτή η ανάπτυξη έχει κλείσει.", - "expenses_receipt_invalid": "Η απόδειξη δεν μπόρεσε να διαβαστεί.", - "deployments_attachment_too_large": "Η απόδειξη είναι πολύ μεγάλη." + "validation": "Ο διακομιστής απέρριψε την αλλαγή." + }, + "expenses": { + "add": "Νέο έξοδο", + "amount": "Ποσό ({{currency}})", + "amountInvalid": "Εισαγάγετε ένα ποσό όπως 42.50.", + "cancel": "Άκυρο", + "choosePhoto": "Επιλογή φωτογραφίας", + "city": "Πόλη", + "description": "Περιγραφή", + "hasReceipt": "απόδειξη", + "noneToday": "Δεν υπάρχουν έξοδα για αυτή την ημέρα.", + "otherDays": "Άλλες ημέρες", + "photoDenied": "Επιτρέψτε την πρόσβαση στην κάμερα ή στις φωτογραφίες για να επισυνάψετε απόδειξη.", + "receiptAttached": "Επισυνάφθηκε φωτογραφία απόδειξης.", + "remove": "Αφαίρεση", + "removeBody": "Αυτό αφαιρεί το έξοδο και την απόδειξή του από την ανάπτυξη.", + "removeTitle": "Αφαίρεση εξόδου;", + "save": "Προσθήκη εξόδου", + "saved": "Το έξοδο αποθηκεύτηκε.", + "takePhoto": "Λήψη φωτογραφίας", + "title": "Έξοδα", + "type": { + "0": "Ημερήσια αποζημίωση γευμάτων", + "1": "Διαμονή", + "2": "Ιδιωτική διαμονή", + "3": "Πορθμείο", + "4": "Καύσιμα", + "5": "Αναπλήρωση υλικών", + "6": "Άλλο" + }, + "typeLabel": "Τύπος εξόδου" }, "financeMode": { "0": "Επιχειρησιακό", @@ -1119,18 +1226,18 @@ "2": "Χρεώσιμο" }, "issues": { + "break_rule": "Μια μεγάλη βάρδια δεν έχει καταγεγραμμένο διάλειμμα.", "end_before_start": "Η ώρα λήξης πρέπει να είναι μετά την ώρα έναρξης.", + "entries_overlap": "Δύο καταχωρίσεις του ίδιου προσώπου ή μονάδας επικαλύπτονται.", + "long_travel": "Περισσότερος χρόνος μετακίνησης από όσο επιτρέπεται σε μία ημέρα· χρειάζεται έγκριση.", "missing_subject": "Επιλέξτε σε ποιον ή σε τι αφορά αυτή η καταχώριση.", "missing_time": "Εισαγάγετε ώρα έναρξης και λήξης.", + "no_entries": "Προσθέστε τουλάχιστον μία καταχώριση.", + "outside_report_date": "Αυτή η καταχώριση είναι εκτός της ημερομηνίας της αναφοράς.", "overlap": "Αυτή η καταχώριση επικαλύπτει άλλη για το ίδιο άτομο ή μονάδα.", - "subject_outside_report_scope": "Αυτή η καταχώριση αφορά κάποιον εκτός του πληρώματος ή του προσώπου αυτής της αναφοράς.", - "subject_on_other_report": "Αυτό το πρόσωπο ή η μονάδα έχει ήδη χρόνο σε άλλη αναφορά την ίδια ημέρα.", "subject_not_on_roster": "Αυτή η καταχώριση αφορά κάποιον που δεν είναι στη σύνθεση.", - "entries_overlap": "Δύο καταχωρίσεις του ίδιου προσώπου ή μονάδας επικαλύπτονται.", - "no_entries": "Προσθέστε τουλάχιστον μία καταχώριση.", - "break_rule": "Μια μεγάλη βάρδια δεν έχει καταγεγραμμένο διάλειμμα.", - "long_travel": "Περισσότερος χρόνος μετακίνησης από όσο επιτρέπεται σε μία ημέρα· χρειάζεται έγκριση.", - "outside_report_date": "Αυτή η καταχώριση είναι εκτός της ημερομηνίας της αναφοράς." + "subject_on_other_report": "Αυτό το πρόσωπο ή η μονάδα έχει ήδη χρόνο σε άλλη αναφορά την ίδια ημέρα.", + "subject_outside_report_scope": "Αυτή η καταχώριση αφορά κάποιον εκτός του πληρώματος ή του προσώπου αυτής της αναφοράς." }, "mars": { "counts": "{{errors}} σφάλματα · {{warnings}} προειδοποιήσεις", @@ -1144,6 +1251,7 @@ "title": "F-42", "validate": "Επικύρωση" }, + "nextDay": "Επόμενη ημέρα", "noNumbers": "Χωρίς αριθμούς συμβάντος ή αιτήματος", "none": "Δεν υπάρχουν αποστολές.", "open": "Ανοικτές", @@ -1153,6 +1261,7 @@ "2": "Συμβάν", "3": "Επιστροφή" }, + "previousDay": "Προηγούμενη ημέρα", "reportStatus": { "0": "Πρόχειρο", "1": "Υποβλήθηκε", @@ -1161,6 +1270,22 @@ "4": "Άκυρο" }, "roster": "{{units}} μονάδες · {{personnel}} άτομα", + "scope": { + "crew": "Πλήρωμα: {{name}}", + "deployment": "Ολόκληρη η ανάπτυξη", + "individual": "{{name}}", + "mine": "Ο χρόνος μου" + }, + "sign": { + "asCrewBoss": "Υπογραφή ως επικεφαλής πληρώματος", + "crewBossPending": "Δεν έχει υπογραφεί ακόμη από τον επικεφαλής πληρώματος.", + "crewBossSigned": "Υπογράφηκε από τον επικεφαλής πληρώματος {{when}}", + "customerName": "Όνομα υπογράφοντος φορέα", + "customerPending": "Δεν έχει καταγραφεί υπογραφή φορέα.", + "customerSigned": "Υπογράφων φορέα: {{name}}", + "recordCustomer": "Καταγραφή υπογραφής φορέα", + "title": "Υπογραφές" + }, "status": { "0": "Προγραμματισμένη", "1": "Αναμονή", @@ -1172,35 +1297,35 @@ "time": { "accommodation": "Διαμονή από την υπηρεσία", "addEntry": "Προσθήκη καταχώρισης", + "applyToCrew": "Εφαρμογή στο πλήρωμα", + "approve": "Έγκριση αναφοράς", + "awaitingApproval": "Αναμένει έγκριση ({{count}})", + "coveredBy": "Αυτός ο χρόνος υπάρχει ήδη στην αναφορά #{{number}} για αυτή την ημέρα.", + "crewReport": "Αναφορά χρόνου πληρώματος #{{number}} · {{name}}", + "dayReports": "Αναφορές αυτής της ημέρας", "from": "Από", "hours": "{{hours}} ώ", + "individualReport": "Αναφορά χρόνου #{{number}} · {{name}}", "meals": "Γεύματα από την υπηρεσία", + "nextDay": "Λήγει την επόμενη ημέρα", + "noCrewReport": "Δεν υπάρχει αναφορά χρόνου πληρώματος για {{name}} αυτή την ημέρα.", "noEntries": "Δεν υπάρχουν καταχωρίσεις ακόμη.", "noReport": "Δεν υπάρχει αναφορά χρόνου για αυτήν την ημέρα.", + "notOnRoster": "Δεν είστε στη σύνθεση ή στο πλήρωμα αυτής της ανάπτυξης.", "notes": "Σημειώσεις", + "readOnly": "Μπορείτε να δείτε αυτή την αναφορά αλλά όχι να την αλλάξετε.", "remove": "Αφαίρεση", "report": "Αναφορά #{{number}}", "save": "Αποθήκευση καταχωρίσεων", "start": "Έναρξη αναφοράς χρόνου", + "startCrew": "Έναρξη αναφοράς χρόνου πληρώματος", "subject": "Ποιος ή τι", "submit": "Υποβολή αναφοράς", "title": "Χρόνος", "to": "Έως", "total": "Σύνολο {{hours}} ώ", "type": "Τύπος καταχώρισης", - "unpaidBreak": "Μη αμειβόμενο διάλειμμα (λεπτά)", - "crewReport": "Αναφορά χρόνου πληρώματος #{{number}} · {{name}}", - "individualReport": "Αναφορά χρόνου #{{number}} · {{name}}", - "noCrewReport": "Δεν υπάρχει αναφορά χρόνου πληρώματος για {{name}} αυτή την ημέρα.", - "coveredBy": "Αυτός ο χρόνος υπάρχει ήδη στην αναφορά #{{number}} για αυτή την ημέρα.", - "startCrew": "Έναρξη αναφοράς χρόνου πληρώματος", - "readOnly": "Μπορείτε να δείτε αυτή την αναφορά αλλά όχι να την αλλάξετε.", - "nextDay": "Λήγει την επόμενη ημέρα", - "applyToCrew": "Εφαρμογή στο πλήρωμα", - "approve": "Έγκριση αναφοράς", - "dayReports": "Αναφορές αυτής της ημέρας", - "awaitingApproval": "Αναμένει έγκριση ({{count}})", - "notOnRoster": "Δεν είστε στη σύνθεση ή στο πλήρωμα αυτής της ανάπτυξης." + "unpaidBreak": "Μη αμειβόμενο διάλειμμα (λεπτά)" }, "title": "Αποστολές", "usage": { @@ -1214,56 +1339,6 @@ "startOdometer": "Χιλιομετρητής έναρξης", "title": "Χρήση", "unit": "Μονάδα" - }, - "crewOf": "Πλήρωμα του {{names}}", - "previousDay": "Προηγούμενη ημέρα", - "nextDay": "Επόμενη ημέρα", - "scope": { - "deployment": "Ολόκληρη η ανάπτυξη", - "mine": "Ο χρόνος μου", - "crew": "Πλήρωμα: {{name}}", - "individual": "{{name}}" - }, - "sign": { - "title": "Υπογραφές", - "crewBossSigned": "Υπογράφηκε από τον επικεφαλής πληρώματος {{when}}", - "crewBossPending": "Δεν έχει υπογραφεί ακόμη από τον επικεφαλής πληρώματος.", - "customerSigned": "Υπογράφων φορέα: {{name}}", - "customerPending": "Δεν έχει καταγραφεί υπογραφή φορέα.", - "asCrewBoss": "Υπογραφή ως επικεφαλής πληρώματος", - "customerName": "Όνομα υπογράφοντος φορέα", - "recordCustomer": "Καταγραφή υπογραφής φορέα" - }, - "expenses": { - "title": "Έξοδα", - "type": { - "0": "Ημερήσια αποζημίωση γευμάτων", - "1": "Διαμονή", - "2": "Ιδιωτική διαμονή", - "3": "Πορθμείο", - "4": "Καύσιμα", - "5": "Αναπλήρωση υλικών", - "6": "Άλλο" - }, - "typeLabel": "Τύπος εξόδου", - "amount": "Ποσό ({{currency}})", - "description": "Περιγραφή", - "city": "Πόλη", - "takePhoto": "Λήψη φωτογραφίας", - "choosePhoto": "Επιλογή φωτογραφίας", - "receiptAttached": "Επισυνάφθηκε φωτογραφία απόδειξης.", - "save": "Προσθήκη εξόδου", - "add": "Νέο έξοδο", - "noneToday": "Δεν υπάρχουν έξοδα για αυτή την ημέρα.", - "otherDays": "Άλλες ημέρες", - "hasReceipt": "απόδειξη", - "remove": "Αφαίρεση", - "removeTitle": "Αφαίρεση εξόδου;", - "removeBody": "Αυτό αφαιρεί το έξοδο και την απόδειξή του από την ανάπτυξη.", - "cancel": "Άκυρο", - "saved": "Το έξοδο αποθηκεύτηκε.", - "amountInvalid": "Εισαγάγετε ένα ποσό όπως 42.50.", - "photoDenied": "Επιτρέψτε την πρόσβαση στην κάμερα ή στις φωτογραφίες για να επισυνάψετε απόδειξη." } }, "protocols": { @@ -1662,6 +1737,7 @@ "background_location": "Τοποθεσία στο Παρασκήνιο", "contact_us": "Επικοινωνήστε Μαζί Μας", "current_unit": "Τρέχουσα Μονάδα", + "custom": "Προσαρμοσμένο", "english": "Αγγλικά", "enter_password": "Εισαγάγετε τον κωδικό πρόσβασής σας", "enter_server_url": "Εισαγάγετε τη διεύθυνση URL του Resgrid API (π.χ., https://api.resgrid.com)", @@ -1772,6 +1848,7 @@ "station_destination_enabled": "Μπορεί να ανταποκριθεί σε σταθμούς", "stations_and_pois_destinations_enabled": "Μπορεί να ανταποκριθεί σε σταθμούς ή σημεία ενδιαφέροντος", "stations_tab": "Σταθμοί", + "status_queued": "Η κατάσταση αποθηκεύτηκε σε αυτή τη συσκευή. Θα αποσταλεί αυτόματα.", "status_saved_successfully": "Η κατάσταση αποθηκεύτηκε με επιτυχία!" }, "tabs": { @@ -1903,80 +1980,5 @@ "unknown": "Άγνωστο" } }, - "welcome": "Καλώς ήρθατε στον ιστότοπο της εφαρμογής Resgrid Unit", - "inventory": { - "title": "Απόθεμα", - "titleFor": "Απόθεμα · {{name}}", - "selectUnit": "Επιλέξτε πρώτα τη μονάδα σας.", - "unnamedLocation": "Χώρος", - "unnamedItem": "Είδος", - "countName": "Έλεγχος {{name}} {{date}}", - "notMigrated": "Το απόθεμα ρυθμίζεται για τον φορέα σας.", - "disabled": "Το απόθεμα είναι απενεργοποιημένο για τον φορέα σας.", - "noLocations": "Δεν έχει αντιστοιχιστεί απόθεμα σε αυτή τη μονάδα.", - "startCount": "Καταμέτρηση αποθέματος", - "wholeUnit": "Ολόκληρη η μονάδα: {{name}}", - "pickLocation": "Πού γίνεται η καταμέτρηση", - "startHelp": "Μετρήστε ό,τι υπάρχει πραγματικά στη μονάδα. Οι διαφορές καταχωρούνται με την ολοκλήρωση.", - "start": "Έναρξη καταμέτρησης", - "cannotCount": "Η καταμέτρηση απαιτεί το δικαίωμα προσαρμογής αποθέματος σε αυτή τη μονάδα.", - "counts": "Πρόσφατες καταμετρήσεις", - "count": "Καταμέτρηση αποθέματος", - "empty": "Δεν υπάρχει τίποτα εδώ.", - "serial": "Σειριακός {{serial}}", - "quantity": "× {{quantity}}", - "back": "Πίσω", - "completeTitle": "Ολοκλήρωση καταμέτρησης;", - "completeBody": "{{count}} είδη διαφέρουν από τα αναμενόμενα. Η ολοκλήρωση καταχωρεί τις διαφορές.", - "completeNoChanges": "Όλα ταιριάζουν με τα αναμενόμενα.", - "cancel": "Άκυρο", - "complete": "Ολοκλήρωση", - "cancelTitle": "Ακύρωση αυτής της καταμέτρησης;", - "cancelBody": "Τίποτα από όσα μετρήθηκαν δεν καταχωρείται.", - "keep": "Συνέχεια καταμέτρησης", - "cancelCount": "Ακύρωση καταμέτρησης", - "progress": "Μετρήθηκαν {{counted}} από {{total}}", - "differences": "{{count}} διαφορές", - "awaitingWitness": "Άλλαξε ελεγχόμενο είδος· ένα δεύτερο πρόσωπο πρέπει να βεβαιώσει την καταμέτρηση στον ιστό.", - "completed": "Η καταμέτρηση ολοκληρώθηκε.", - "expected": "Αναμενόμενο {{quantity}} {{unit}}", - "present": "Υπάρχει", - "missing": "Λείπει", - "less": "Ένα λιγότερο", - "more": "Ένα περισσότερο", - "matches": "Ταιριάζει", - "save": "Αποθήκευση προόδου", - "countStatus": { - "0": "Σε εξέλιξη", - "1": "Αναμένει μάρτυρα", - "2": "Ολοκληρώθηκε", - "3": "Ακυρώθηκε" - }, - "assetStatus": { - "0": "Σε υπηρεσία", - "1": "Διατέθηκε", - "2": "Σε επισκευή", - "3": "Κατεστραμμένο", - "4": "Χαμένο", - "5": "Αναλώθηκε", - "6": "Αποσύρθηκε" - }, - "errors": { - "retry": "Δεν ήταν δυνατή η σύνδεση με τον διακομιστή. Δοκιμάστε ξανά.", - "locked": "Ξεκλειδώστε τα προστατευμένα δεδομένα για να συνεχίσετε.", - "PermissionRequired": "Δεν έχετε δικαίωμα για αυτή την ενέργεια.", - "MembershipRequired": "Δεν είστε ενεργό μέλος αυτού του φορέα.", - "InventoryDisabled": "Το απόθεμα είναι απενεργοποιημένο για τον φορέα σας.", - "MigrationRequired": "Το απόθεμα ρυθμίζεται για τον φορέα σας.", - "CountScopeTooLarge": "Πάρα πολλά είδη για μία καταμέτρηση. Μετρήστε έναν χώρο τη φορά.", - "CountEmpty": "Δεν υπάρχει τίποτα για καταμέτρηση εδώ.", - "CountSnapshotChanged": "Το απόθεμα της μονάδας άλλαξε κατά την καταμέτρηση. Ακυρώστε και ξεκινήστε ξανά.", - "CountStateConflict": "Αυτή η καταμέτρηση άλλαξε σε άλλη συσκευή. Ανοίξτε την ξανά.", - "ReturnAssetFirst": "Ένα είδος που σημειώθηκε ως χαμένο είναι ακόμη διατεθειμένο σε κάποιον. Επιστρέψτε το πρώτα στον ιστό.", - "ProtectedDataRequired": "Ξεκλειδώστε τα προστατευμένα δεδομένα για να συνεχίσετε.", - "CountAlreadyExists": "Αυτή η καταμέτρηση υπάρχει ήδη.", - "LocationUnavailable": "Αυτή η τοποθεσία δεν είναι διαθέσιμη.", - "InvalidCount": "Ελέγξτε τις μετρημένες τιμές και δοκιμάστε ξανά." - } - } + "welcome": "Καλώς ήρθατε στον ιστότοπο της εφαρμογής Resgrid Unit" } diff --git a/src/translations/en.json b/src/translations/en.json index 08e0797e..cea38620 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -716,6 +716,7 @@ "address": "Address", "bluesky": "Bluesky", "cancel": "Cancel", + "categoryLabel": "Category: {{name}}", "cellPhone": "Cell Phone", "city": "City", "cityState": "City & State", @@ -729,6 +730,7 @@ "contactNotesLoading": "Loading contact notes...", "contactType": "Contact Type", "countryId": "Country ID", + "customFields": "Custom fields", "delete": "Delete", "deleteConfirm": "Are you sure you want to delete this contact?", "deleteSuccess": "Contact deleted successfully", @@ -753,6 +755,7 @@ "share_unavailable": "Sharing is not available on this device." }, "formError": "Please fix the errors in the form", + "hasGeofence": "This location has a mapped geofence.", "homePhone": "Home Phone", "identification": "Identification", "important": "Mark as Important", @@ -762,6 +765,7 @@ "linkedin": "LinkedIn", "locationCoordinates": "Location Coordinates", "locationInformation": "Location Information", + "mailingAddress": "Mailing address", "mastodon": "Mastodon", "mobile": "Mobile", "name": "Name", @@ -773,6 +777,7 @@ "otherInfo": "Other Information", "person": "Person", "phone": "Phone", + "physicalAddress": "Physical address", "preplan": { "access": "Access", "access_notes": "Access notes", @@ -838,13 +843,8 @@ "unknown_person": "Unknown Person", "visibility": "Visibility", "website": "Website", - "zip": "Zip Code", "withheld": "{{count}} protected details are hidden.", - "categoryLabel": "Category: {{name}}", - "physicalAddress": "Physical address", - "mailingAddress": "Mailing address", - "hasGeofence": "This location has a mapped geofence.", - "customFields": "Custom fields" + "zip": "Zip Code" }, "data_protection": { "conceal": "Hide again", @@ -935,6 +935,81 @@ "send_message": "Message", "tab_title": "Command" }, + "inventory": { + "assetStatus": { + "0": "In service", + "1": "Issued", + "2": "Out for repair", + "3": "Damaged", + "4": "Lost", + "5": "Consumed", + "6": "Retired" + }, + "awaitingWitness": "A controlled item changed; a second person must witness this count on the web.", + "back": "Back", + "cancel": "Cancel", + "cancelBody": "Nothing counted so far is posted.", + "cancelCount": "Cancel count", + "cancelTitle": "Cancel this count?", + "cannotCount": "Counting needs the Adjust Inventory permission at this unit.", + "complete": "Complete", + "completeBody": "{{count}} items differ from what was expected. Completing posts the differences.", + "completeNoChanges": "Everything matches what was expected.", + "completeTitle": "Complete count?", + "completed": "Count completed.", + "count": "Inventory count", + "countName": "{{name}} check {{date}}", + "countStatus": { + "0": "In progress", + "1": "Awaiting witness", + "2": "Completed", + "3": "Cancelled" + }, + "counts": "Recent counts", + "differences": "{{count}} differences", + "disabled": "Inventory is turned off for your department.", + "empty": "Nothing here.", + "errors": { + "CountAlreadyExists": "This count already exists.", + "CountEmpty": "There is nothing to count here.", + "CountScopeTooLarge": "Too many items for one count. Count one compartment at a time.", + "CountSnapshotChanged": "Inventory at this unit changed while you counted. Cancel this count and start again.", + "CountStateConflict": "This count changed on another device. Reopen it and try again.", + "InvalidCount": "Check the counted values and try again.", + "InventoryDisabled": "Inventory is turned off for your department.", + "LocationUnavailable": "That location is not available.", + "MembershipRequired": "You are not an active member of this department.", + "MigrationRequired": "Inventory is being set up for your department.", + "PermissionRequired": "You do not have permission to do this.", + "ProtectedDataRequired": "Unlock protected data to continue.", + "ReturnAssetFirst": "An item marked missing is still issued to someone. Return it first on the web.", + "locked": "Unlock protected data to continue.", + "retry": "Could not reach the server. Try again." + }, + "expected": "Expected {{quantity}} {{unit}}", + "keep": "Keep counting", + "less": "One less", + "matches": "Matches", + "missing": "Missing", + "more": "One more", + "noLocations": "No inventory is assigned to this unit.", + "notMigrated": "Inventory is being set up for your department.", + "pickLocation": "Where to count", + "present": "Present", + "progress": "{{counted}} of {{total}} counted", + "quantity": "× {{quantity}}", + "save": "Save progress", + "selectUnit": "Select your unit first.", + "serial": "Serial {{serial}}", + "start": "Start count", + "startCount": "Count the inventory", + "startHelp": "Count what is actually on the unit. Differences are posted when you complete the count.", + "title": "Inventory", + "titleFor": "Inventory · {{name}}", + "unnamedItem": "Item", + "unnamedLocation": "Compartment", + "wholeUnit": "Whole unit: {{name}}" + }, "livekit": { "audio_devices": "Audio Devices", "audio_settings": "Audio Settings", @@ -1064,14 +1139,17 @@ "title": "Notes" }, "notifications": { + "additional_info": "Additional Information", "confirm_delete_message_count": "Are you sure you want to delete {{count}} notifications? This action cannot be undone.", "confirm_delete_message_one": "Are you sure you want to delete this notification? This action cannot be undone.", "confirm_delete_title": "Confirm Delete", "delete_selected": "Delete selected notifications", "deselect_all": "Deselect All", + "detail_title": "Notification", "empty": "No updates available", "enter_selection_mode": "Select notifications", "open": "Open notifications", + "open_reference": "Open", "remove_failed_count": "Failed to remove notifications", "remove_failed_one": "Failed to remove notification", "removed_count": "{{count}} notifications removed", @@ -1080,11 +1158,8 @@ "selected_count": "{{count}} selected", "title": "Notifications", "unable_to_load": "Unable to load notifications", - "detail_title": "Notification", - "additional_info": "Additional Information", "view_call": "View Call", - "view_chat": "View Chat", - "open_reference": "Open" + "view_chat": "View Chat" }, "onboarding": { "message": "Welcome to Resgrid Unit app site" @@ -1092,6 +1167,7 @@ "operations": { "all": "All", "back": "Back", + "crewOf": "Crew of {{names}}", "disabled": "Deployments are not enabled for this department.", "entryType": { "0": "Deployment", @@ -1100,18 +1176,49 @@ }, "errors": { "denied": "You do not have access to this deployment.", + "deployments_attachment_too_large": "The receipt is too large.", + "deployments_closed": "This deployment is closed.", "disabled": "This feature is turned off for your department.", + "expenses_receipt_invalid": "The receipt could not be read.", "locked": "Unlock protected data to continue.", "retry": "Could not reach the server. Try again.", - "validation": "The server rejected the change.", - "timereports_subject_covered": "That crew or person already has time on another report for this day.", "timereports_date_exists": "A report for this crew or person already exists for that day.", - "timereports_scope_invalid": "That crew or person is not active on this deployment.", "timereports_locked": "This report can no longer be changed.", + "timereports_scope_invalid": "That crew or person is not active on this deployment.", + "timereports_subject_covered": "That crew or person already has time on another report for this day.", "timereports_time_invalid": "Enter times as HH:mm.", - "deployments_closed": "This deployment is closed.", - "expenses_receipt_invalid": "The receipt could not be read.", - "deployments_attachment_too_large": "The receipt is too large." + "validation": "The server rejected the change." + }, + "expenses": { + "add": "New expense", + "amount": "Amount ({{currency}})", + "amountInvalid": "Enter an amount such as 42.50.", + "cancel": "Cancel", + "choosePhoto": "Choose photo", + "city": "City", + "description": "Description", + "hasReceipt": "receipt", + "noneToday": "No expenses for this day.", + "otherDays": "Other days", + "photoDenied": "Allow camera or photo access to attach a receipt.", + "receiptAttached": "Receipt photo attached.", + "remove": "Remove", + "removeBody": "This removes the expense and its receipt from the deployment.", + "removeTitle": "Remove expense?", + "save": "Add expense", + "saved": "Expense saved.", + "takePhoto": "Take photo", + "title": "Expenses", + "type": { + "0": "Meal per diem", + "1": "Lodging", + "2": "Private lodging", + "3": "Ferry", + "4": "Fuel", + "5": "Supply restock", + "6": "Other" + }, + "typeLabel": "Expense type" }, "financeMode": { "0": "Operational", @@ -1119,18 +1226,18 @@ "2": "Billable" }, "issues": { + "break_rule": "A long shift has no break recorded.", "end_before_start": "End time must be after the start time.", + "entries_overlap": "Two entries overlap for the same person or unit.", + "long_travel": "More travel than allowed in one day; it needs approval.", "missing_subject": "Pick who or what this entry is for.", "missing_time": "Enter a start and end time.", + "no_entries": "Add at least one entry.", + "outside_report_date": "This entry falls outside the report date.", "overlap": "This entry overlaps another for the same person or unit.", - "subject_outside_report_scope": "This entry is for someone outside this report's crew or person.", - "subject_on_other_report": "This person or unit already has time on another report for the same day.", "subject_not_on_roster": "This entry is for someone who is not on the roster.", - "entries_overlap": "Two entries overlap for the same person or unit.", - "no_entries": "Add at least one entry.", - "break_rule": "A long shift has no break recorded.", - "long_travel": "More travel than allowed in one day; it needs approval.", - "outside_report_date": "This entry falls outside the report date." + "subject_on_other_report": "This person or unit already has time on another report for the same day.", + "subject_outside_report_scope": "This entry is for someone outside this report's crew or person." }, "mars": { "counts": "{{errors}} errors · {{warnings}} warnings", @@ -1144,6 +1251,7 @@ "title": "F-42", "validate": "Validate" }, + "nextDay": "Next day", "noNumbers": "No incident or request numbers", "none": "No deployments.", "open": "Open", @@ -1153,6 +1261,7 @@ "2": "Incident", "3": "Return" }, + "previousDay": "Previous day", "reportStatus": { "0": "Draft", "1": "Submitted", @@ -1161,6 +1270,22 @@ "4": "Void" }, "roster": "{{units}} units · {{personnel}} personnel", + "scope": { + "crew": "Crew: {{name}}", + "deployment": "Whole deployment", + "individual": "{{name}}", + "mine": "My time" + }, + "sign": { + "asCrewBoss": "Sign as crew boss", + "crewBossPending": "Not signed by the crew boss yet.", + "crewBossSigned": "Crew boss signed {{when}}", + "customerName": "Agency signer name", + "customerPending": "No agency signature recorded.", + "customerSigned": "Agency signer: {{name}}", + "recordCustomer": "Record agency signature", + "title": "Signatures" + }, "status": { "0": "Planned", "1": "Standby", @@ -1172,35 +1297,35 @@ "time": { "accommodation": "Agency supplied accommodation", "addEntry": "Add entry", + "applyToCrew": "Apply to crew", + "approve": "Approve report", + "awaitingApproval": "Awaiting approval ({{count}})", + "coveredBy": "This time is already on report #{{number}} for this day.", + "crewReport": "Crew time report #{{number}} · {{name}}", + "dayReports": "Reports this day", "from": "From", "hours": "{{hours}} h", + "individualReport": "Time report #{{number}} · {{name}}", "meals": "Agency supplied meals", + "nextDay": "Ends the next day", + "noCrewReport": "No crew time report for {{name}} on this day.", "noEntries": "No entries yet.", "noReport": "No time report for this day.", + "notOnRoster": "You are not on this deployment's roster or crew.", "notes": "Notes", + "readOnly": "You can view this report but not change it.", "remove": "Remove", "report": "Report #{{number}}", "save": "Save entries", "start": "Start time report", + "startCrew": "Start crew time report", "subject": "Who or what", "submit": "Submit report", "title": "Time", "to": "To", "total": "Total {{hours}} h", "type": "Entry type", - "unpaidBreak": "Unpaid break (min)", - "crewReport": "Crew time report #{{number}} · {{name}}", - "individualReport": "Time report #{{number}} · {{name}}", - "noCrewReport": "No crew time report for {{name}} on this day.", - "coveredBy": "This time is already on report #{{number}} for this day.", - "startCrew": "Start crew time report", - "readOnly": "You can view this report but not change it.", - "nextDay": "Ends the next day", - "applyToCrew": "Apply to crew", - "approve": "Approve report", - "dayReports": "Reports this day", - "awaitingApproval": "Awaiting approval ({{count}})", - "notOnRoster": "You are not on this deployment's roster or crew." + "unpaidBreak": "Unpaid break (min)" }, "title": "Deployments", "usage": { @@ -1214,56 +1339,6 @@ "startOdometer": "Start odometer", "title": "Usage", "unit": "Unit" - }, - "crewOf": "Crew of {{names}}", - "previousDay": "Previous day", - "nextDay": "Next day", - "scope": { - "deployment": "Whole deployment", - "mine": "My time", - "crew": "Crew: {{name}}", - "individual": "{{name}}" - }, - "sign": { - "title": "Signatures", - "crewBossSigned": "Crew boss signed {{when}}", - "crewBossPending": "Not signed by the crew boss yet.", - "customerSigned": "Agency signer: {{name}}", - "customerPending": "No agency signature recorded.", - "asCrewBoss": "Sign as crew boss", - "customerName": "Agency signer name", - "recordCustomer": "Record agency signature" - }, - "expenses": { - "title": "Expenses", - "type": { - "0": "Meal per diem", - "1": "Lodging", - "2": "Private lodging", - "3": "Ferry", - "4": "Fuel", - "5": "Supply restock", - "6": "Other" - }, - "typeLabel": "Expense type", - "amount": "Amount ({{currency}})", - "description": "Description", - "city": "City", - "takePhoto": "Take photo", - "choosePhoto": "Choose photo", - "receiptAttached": "Receipt photo attached.", - "save": "Add expense", - "add": "New expense", - "noneToday": "No expenses for this day.", - "otherDays": "Other days", - "hasReceipt": "receipt", - "remove": "Remove", - "removeTitle": "Remove expense?", - "removeBody": "This removes the expense and its receipt from the deployment.", - "cancel": "Cancel", - "saved": "Expense saved.", - "amountInvalid": "Enter an amount such as 42.50.", - "photoDenied": "Allow camera or photo access to attach a receipt." } }, "protocols": { @@ -1662,6 +1737,7 @@ "background_location": "Background Location", "contact_us": "Contact Us", "current_unit": "Current Unit", + "custom": "Custom", "english": "English", "enter_password": "Enter your password", "enter_server_url": "Enter Resgrid API URL (e.g., https://api.resgrid.com)", @@ -1772,6 +1848,7 @@ "station_destination_enabled": "Can respond to stations", "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Stations", + "status_queued": "Status saved on this device. It will be sent automatically.", "status_saved_successfully": "Status saved successfully!" }, "tabs": { @@ -1903,80 +1980,5 @@ "unknown": "Unknown" } }, - "welcome": "Welcome to Resgrid Unit app site", - "inventory": { - "title": "Inventory", - "titleFor": "Inventory · {{name}}", - "selectUnit": "Select your unit first.", - "unnamedLocation": "Compartment", - "unnamedItem": "Item", - "countName": "{{name}} check {{date}}", - "notMigrated": "Inventory is being set up for your department.", - "disabled": "Inventory is turned off for your department.", - "noLocations": "No inventory is assigned to this unit.", - "startCount": "Count the inventory", - "wholeUnit": "Whole unit: {{name}}", - "pickLocation": "Where to count", - "startHelp": "Count what is actually on the unit. Differences are posted when you complete the count.", - "start": "Start count", - "cannotCount": "Counting needs the Adjust Inventory permission at this unit.", - "counts": "Recent counts", - "count": "Inventory count", - "empty": "Nothing here.", - "serial": "Serial {{serial}}", - "quantity": "× {{quantity}}", - "back": "Back", - "completeTitle": "Complete count?", - "completeBody": "{{count}} items differ from what was expected. Completing posts the differences.", - "completeNoChanges": "Everything matches what was expected.", - "cancel": "Cancel", - "complete": "Complete", - "cancelTitle": "Cancel this count?", - "cancelBody": "Nothing counted so far is posted.", - "keep": "Keep counting", - "cancelCount": "Cancel count", - "progress": "{{counted}} of {{total}} counted", - "differences": "{{count}} differences", - "awaitingWitness": "A controlled item changed; a second person must witness this count on the web.", - "completed": "Count completed.", - "expected": "Expected {{quantity}} {{unit}}", - "present": "Present", - "missing": "Missing", - "less": "One less", - "more": "One more", - "matches": "Matches", - "save": "Save progress", - "countStatus": { - "0": "In progress", - "1": "Awaiting witness", - "2": "Completed", - "3": "Cancelled" - }, - "assetStatus": { - "0": "In service", - "1": "Issued", - "2": "Out for repair", - "3": "Damaged", - "4": "Lost", - "5": "Consumed", - "6": "Retired" - }, - "errors": { - "retry": "Could not reach the server. Try again.", - "locked": "Unlock protected data to continue.", - "PermissionRequired": "You do not have permission to do this.", - "MembershipRequired": "You are not an active member of this department.", - "InventoryDisabled": "Inventory is turned off for your department.", - "MigrationRequired": "Inventory is being set up for your department.", - "CountScopeTooLarge": "Too many items for one count. Count one compartment at a time.", - "CountEmpty": "There is nothing to count here.", - "CountSnapshotChanged": "Inventory at this unit changed while you counted. Cancel this count and start again.", - "CountStateConflict": "This count changed on another device. Reopen it and try again.", - "ReturnAssetFirst": "An item marked missing is still issued to someone. Return it first on the web.", - "ProtectedDataRequired": "Unlock protected data to continue.", - "CountAlreadyExists": "This count already exists.", - "LocationUnavailable": "That location is not available.", - "InvalidCount": "Check the counted values and try again." - } - } + "welcome": "Welcome to Resgrid Unit app site" } diff --git a/src/translations/es.json b/src/translations/es.json index f2119f14..d934e924 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -716,6 +716,7 @@ "address": "Dirección", "bluesky": "Bluesky", "cancel": "Cancelar", + "categoryLabel": "Categoría: {{name}}", "cellPhone": "Teléfono móvil", "city": "Ciudad", "cityState": "Ciudad y Estado", @@ -729,6 +730,7 @@ "contactNotesLoading": "Cargando notas del contacto...", "contactType": "Tipo de contacto", "countryId": "ID del país", + "customFields": "Campos personalizados", "delete": "Eliminar", "deleteConfirm": "¿Estás seguro de que quieres eliminar este contacto?", "deleteSuccess": "Contacto eliminado con éxito", @@ -753,6 +755,7 @@ "share_unavailable": "Compartir no está disponible en este dispositivo." }, "formError": "Por favor, corrige los errores en el formulario", + "hasGeofence": "Esta ubicación tiene una geocerca en el mapa.", "homePhone": "Teléfono de casa", "identification": "Identificación", "important": "Marcar como importante", @@ -762,6 +765,7 @@ "linkedin": "LinkedIn", "locationCoordinates": "Coordenadas de ubicación", "locationInformation": "Información de ubicación", + "mailingAddress": "Dirección postal", "mastodon": "Mastodon", "mobile": "Móvil", "name": "Nombre", @@ -773,6 +777,7 @@ "otherInfo": "Otra información", "person": "Persona", "phone": "Teléfono", + "physicalAddress": "Dirección física", "preplan": { "access": "Acceso", "access_notes": "Notas de acceso", @@ -838,13 +843,8 @@ "unknown_person": "Persona desconocida", "visibility": "Visibilidad", "website": "Sitio web", - "zip": "Código postal", "withheld": "Hay {{count}} datos protegidos ocultos.", - "categoryLabel": "Categoría: {{name}}", - "physicalAddress": "Dirección física", - "mailingAddress": "Dirección postal", - "hasGeofence": "Esta ubicación tiene una geocerca en el mapa.", - "customFields": "Campos personalizados" + "zip": "Código postal" }, "data_protection": { "conceal": "Ocultar de nuevo", @@ -935,6 +935,81 @@ "send_message": "Message", "tab_title": "Comando" }, + "inventory": { + "assetStatus": { + "0": "En servicio", + "1": "Entregado", + "2": "En reparación", + "3": "Dañado", + "4": "Perdido", + "5": "Consumido", + "6": "Retirado" + }, + "awaitingWitness": "Cambió un artículo controlado; una segunda persona debe atestiguar este recuento en la web.", + "back": "Atrás", + "cancel": "Cancelar", + "cancelBody": "No se registrará nada de lo contado.", + "cancelCount": "Cancelar recuento", + "cancelTitle": "¿Cancelar este recuento?", + "cannotCount": "Contar requiere el permiso de ajustar inventario en esta unidad.", + "complete": "Completar", + "completeBody": "{{count}} artículos difieren de lo esperado. Completar registra las diferencias.", + "completeNoChanges": "Todo coincide con lo esperado.", + "completeTitle": "¿Completar el recuento?", + "completed": "Recuento completado.", + "count": "Recuento de inventario", + "countName": "Revisión {{name}} {{date}}", + "countStatus": { + "0": "En curso", + "1": "Pendiente de testigo", + "2": "Completado", + "3": "Cancelado" + }, + "counts": "Recuentos recientes", + "differences": "{{count}} diferencias", + "disabled": "El inventario está desactivado para su organización.", + "empty": "Aquí no hay nada.", + "errors": { + "CountAlreadyExists": "Este recuento ya existe.", + "CountEmpty": "Aquí no hay nada que contar.", + "CountScopeTooLarge": "Demasiados artículos para un recuento. Cuente un compartimento cada vez.", + "CountSnapshotChanged": "El inventario de esta unidad cambió mientras contaba. Cancele el recuento y empiece de nuevo.", + "CountStateConflict": "Este recuento cambió en otro dispositivo. Vuelva a abrirlo e inténtelo de nuevo.", + "InvalidCount": "Revise los valores contados e inténtelo de nuevo.", + "InventoryDisabled": "El inventario está desactivado para su organización.", + "LocationUnavailable": "Esa ubicación no está disponible.", + "MembershipRequired": "No es un miembro activo de esta organización.", + "MigrationRequired": "El inventario se está configurando para su organización.", + "PermissionRequired": "No tiene permiso para hacer esto.", + "ProtectedDataRequired": "Desbloquee los datos protegidos para continuar.", + "ReturnAssetFirst": "Un artículo marcado como faltante sigue entregado a alguien. Devuélvalo primero en la web.", + "locked": "Desbloquee los datos protegidos para continuar.", + "retry": "No se pudo conectar con el servidor. Inténtelo de nuevo." + }, + "expected": "Esperado {{quantity}} {{unit}}", + "keep": "Seguir contando", + "less": "Uno menos", + "matches": "Coincide", + "missing": "Falta", + "more": "Uno más", + "noLocations": "Esta unidad no tiene inventario asignado.", + "notMigrated": "El inventario se está configurando para su organización.", + "pickLocation": "Dónde contar", + "present": "Presente", + "progress": "{{counted}} de {{total}} contados", + "quantity": "× {{quantity}}", + "save": "Guardar progreso", + "selectUnit": "Seleccione primero su unidad.", + "serial": "N.º de serie {{serial}}", + "start": "Iniciar recuento", + "startCount": "Contar el inventario", + "startHelp": "Cuente lo que realmente hay en la unidad. Las diferencias se registran al completar el recuento.", + "title": "Inventario", + "titleFor": "Inventario · {{name}}", + "unnamedItem": "Artículo", + "unnamedLocation": "Compartimento", + "wholeUnit": "Unidad completa: {{name}}" + }, "livekit": { "audio_devices": "Dispositivos de audio", "audio_settings": "Configuración de audio", @@ -1064,14 +1139,17 @@ "title": "Notas" }, "notifications": { + "additional_info": "Información adicional", "confirm_delete_message_count": "¿Seguro que quieres eliminar {{count}} notificaciones? Esta acción no se puede deshacer.", "confirm_delete_message_one": "¿Seguro que quieres eliminar esta notificación? Esta acción no se puede deshacer.", "confirm_delete_title": "Confirmar eliminación", "delete_selected": "Eliminar las notificaciones seleccionadas", "deselect_all": "Deseleccionar todo", + "detail_title": "Notificación", "empty": "No hay novedades", "enter_selection_mode": "Seleccionar notificaciones", "open": "Abrir notificaciones", + "open_reference": "Abrir", "remove_failed_count": "No se pudieron eliminar las notificaciones", "remove_failed_one": "No se pudo eliminar la notificación", "removed_count": "{{count}} notificaciones eliminadas", @@ -1080,11 +1158,8 @@ "selected_count": "{{count}} seleccionadas", "title": "Notificaciones", "unable_to_load": "No se pudieron cargar las notificaciones", - "detail_title": "Notificación", - "additional_info": "Información adicional", "view_call": "Ver Llamada", - "view_chat": "Ver chat", - "open_reference": "Abrir" + "view_chat": "Ver chat" }, "onboarding": { "message": "Bienvenido al sitio de la aplicación Resgrid Unit" @@ -1092,6 +1167,7 @@ "operations": { "all": "Todos", "back": "Atrás", + "crewOf": "Tripulación de {{names}}", "disabled": "Los despliegues no están habilitados para este departamento.", "entryType": { "0": "Despliegue", @@ -1100,18 +1176,49 @@ }, "errors": { "denied": "No tiene acceso a este despliegue.", + "deployments_attachment_too_large": "El recibo es demasiado grande.", + "deployments_closed": "Este despliegue está cerrado.", "disabled": "Esta función está desactivada para su departamento.", + "expenses_receipt_invalid": "No se pudo leer el recibo.", "locked": "Desbloquee los datos protegidos para continuar.", "retry": "No se pudo conectar con el servidor. Inténtelo de nuevo.", - "validation": "El servidor rechazó el cambio.", - "timereports_subject_covered": "Esa tripulación o persona ya tiene horas en otro parte de este día.", "timereports_date_exists": "Ya existe un parte para esa tripulación o persona ese día.", - "timereports_scope_invalid": "Esa tripulación o persona no está activa en este despliegue.", "timereports_locked": "Este parte ya no se puede modificar.", + "timereports_scope_invalid": "Esa tripulación o persona no está activa en este despliegue.", + "timereports_subject_covered": "Esa tripulación o persona ya tiene horas en otro parte de este día.", "timereports_time_invalid": "Introduzca las horas como HH:mm.", - "deployments_closed": "Este despliegue está cerrado.", - "expenses_receipt_invalid": "No se pudo leer el recibo.", - "deployments_attachment_too_large": "El recibo es demasiado grande." + "validation": "El servidor rechazó el cambio." + }, + "expenses": { + "add": "Nuevo gasto", + "amount": "Importe ({{currency}})", + "amountInvalid": "Introduzca un importe como 42.50.", + "cancel": "Cancelar", + "choosePhoto": "Elegir foto", + "city": "Ciudad", + "description": "Descripción", + "hasReceipt": "recibo", + "noneToday": "No hay gastos este día.", + "otherDays": "Otros días", + "photoDenied": "Permita el acceso a la cámara o a las fotos para adjuntar un recibo.", + "receiptAttached": "Foto del recibo adjunta.", + "remove": "Quitar", + "removeBody": "Esto quita el gasto y su recibo del despliegue.", + "removeTitle": "¿Quitar el gasto?", + "save": "Añadir gasto", + "saved": "Gasto guardado.", + "takePhoto": "Hacer foto", + "title": "Gastos", + "type": { + "0": "Dieta de comidas", + "1": "Alojamiento", + "2": "Alojamiento privado", + "3": "Ferry", + "4": "Combustible", + "5": "Reposición de material", + "6": "Otro" + }, + "typeLabel": "Tipo de gasto" }, "financeMode": { "0": "Operativo", @@ -1119,18 +1226,18 @@ "2": "Facturable" }, "issues": { + "break_rule": "Un turno largo no tiene descanso registrado.", "end_before_start": "La hora de fin debe ser posterior a la de inicio.", + "entries_overlap": "Dos entradas de la misma persona o unidad se solapan.", + "long_travel": "Más viaje del permitido en un día; necesita aprobación.", "missing_subject": "Elija a quién o a qué corresponde esta entrada.", "missing_time": "Introduzca una hora de inicio y de fin.", + "no_entries": "Añada al menos una entrada.", + "outside_report_date": "Esta entrada queda fuera de la fecha del parte.", "overlap": "Esta entrada se solapa con otra de la misma persona o unidad.", - "subject_outside_report_scope": "Esta entrada es de alguien ajeno a la tripulación o persona de este parte.", - "subject_on_other_report": "Esta persona o unidad ya tiene horas en otro parte del mismo día.", "subject_not_on_roster": "Esta entrada es de alguien que no figura en la dotación.", - "entries_overlap": "Dos entradas de la misma persona o unidad se solapan.", - "no_entries": "Añada al menos una entrada.", - "break_rule": "Un turno largo no tiene descanso registrado.", - "long_travel": "Más viaje del permitido en un día; necesita aprobación.", - "outside_report_date": "Esta entrada queda fuera de la fecha del parte." + "subject_on_other_report": "Esta persona o unidad ya tiene horas en otro parte del mismo día.", + "subject_outside_report_scope": "Esta entrada es de alguien ajeno a la tripulación o persona de este parte." }, "mars": { "counts": "{{errors}} errores · {{warnings}} avisos", @@ -1144,6 +1251,7 @@ "title": "F-42", "validate": "Validar" }, + "nextDay": "Día siguiente", "noNumbers": "Sin números de incidente o solicitud", "none": "No hay despliegues.", "open": "Abiertos", @@ -1153,6 +1261,7 @@ "2": "Incidente", "3": "Regreso" }, + "previousDay": "Día anterior", "reportStatus": { "0": "Borrador", "1": "Enviado", @@ -1161,6 +1270,22 @@ "4": "Anulado" }, "roster": "{{units}} unidades · {{personnel}} personas", + "scope": { + "crew": "Tripulación: {{name}}", + "deployment": "Todo el despliegue", + "individual": "{{name}}", + "mine": "Mis horas" + }, + "sign": { + "asCrewBoss": "Firmar como jefe de tripulación", + "crewBossPending": "Aún sin firma del jefe de tripulación.", + "crewBossSigned": "Firmado por el jefe de tripulación {{when}}", + "customerName": "Nombre del firmante del organismo", + "customerPending": "No hay firma del organismo registrada.", + "customerSigned": "Firmante del organismo: {{name}}", + "recordCustomer": "Registrar firma del organismo", + "title": "Firmas" + }, "status": { "0": "Planificado", "1": "En espera", @@ -1172,35 +1297,35 @@ "time": { "accommodation": "Alojamiento provisto por la agencia", "addEntry": "Añadir entrada", + "applyToCrew": "Aplicar a la tripulación", + "approve": "Aprobar parte", + "awaitingApproval": "Pendientes de aprobación ({{count}})", + "coveredBy": "Estas horas ya están en el parte #{{number}} de este día.", + "crewReport": "Parte de horas de tripulación #{{number}} · {{name}}", + "dayReports": "Partes de este día", "from": "De", "hours": "{{hours}} h", + "individualReport": "Parte de horas #{{number}} · {{name}}", "meals": "Comidas provistas por la agencia", + "nextDay": "Termina al día siguiente", + "noCrewReport": "No hay parte de tripulación para {{name}} este día.", "noEntries": "Todavía no hay entradas.", "noReport": "No hay parte de tiempo para este día.", + "notOnRoster": "No figura en la dotación ni en una tripulación de este despliegue.", "notes": "Notas", + "readOnly": "Puede ver este parte pero no modificarlo.", "remove": "Quitar", "report": "Parte #{{number}}", "save": "Guardar entradas", "start": "Iniciar parte de tiempo", + "startCrew": "Iniciar parte de tripulación", "subject": "Quién o qué", "submit": "Enviar parte", "title": "Tiempo", "to": "A", "total": "Total {{hours}} h", "type": "Tipo de entrada", - "unpaidBreak": "Descanso no pagado (min)", - "crewReport": "Parte de horas de tripulación #{{number}} · {{name}}", - "individualReport": "Parte de horas #{{number}} · {{name}}", - "noCrewReport": "No hay parte de tripulación para {{name}} este día.", - "coveredBy": "Estas horas ya están en el parte #{{number}} de este día.", - "startCrew": "Iniciar parte de tripulación", - "readOnly": "Puede ver este parte pero no modificarlo.", - "nextDay": "Termina al día siguiente", - "applyToCrew": "Aplicar a la tripulación", - "approve": "Aprobar parte", - "dayReports": "Partes de este día", - "awaitingApproval": "Pendientes de aprobación ({{count}})", - "notOnRoster": "No figura en la dotación ni en una tripulación de este despliegue." + "unpaidBreak": "Descanso no pagado (min)" }, "title": "Despliegues", "usage": { @@ -1214,56 +1339,6 @@ "startOdometer": "Odómetro inicial", "title": "Uso", "unit": "Unidad" - }, - "crewOf": "Tripulación de {{names}}", - "previousDay": "Día anterior", - "nextDay": "Día siguiente", - "scope": { - "deployment": "Todo el despliegue", - "mine": "Mis horas", - "crew": "Tripulación: {{name}}", - "individual": "{{name}}" - }, - "sign": { - "title": "Firmas", - "crewBossSigned": "Firmado por el jefe de tripulación {{when}}", - "crewBossPending": "Aún sin firma del jefe de tripulación.", - "customerSigned": "Firmante del organismo: {{name}}", - "customerPending": "No hay firma del organismo registrada.", - "asCrewBoss": "Firmar como jefe de tripulación", - "customerName": "Nombre del firmante del organismo", - "recordCustomer": "Registrar firma del organismo" - }, - "expenses": { - "title": "Gastos", - "type": { - "0": "Dieta de comidas", - "1": "Alojamiento", - "2": "Alojamiento privado", - "3": "Ferry", - "4": "Combustible", - "5": "Reposición de material", - "6": "Otro" - }, - "typeLabel": "Tipo de gasto", - "amount": "Importe ({{currency}})", - "description": "Descripción", - "city": "Ciudad", - "takePhoto": "Hacer foto", - "choosePhoto": "Elegir foto", - "receiptAttached": "Foto del recibo adjunta.", - "save": "Añadir gasto", - "add": "Nuevo gasto", - "noneToday": "No hay gastos este día.", - "otherDays": "Otros días", - "hasReceipt": "recibo", - "remove": "Quitar", - "removeTitle": "¿Quitar el gasto?", - "removeBody": "Esto quita el gasto y su recibo del despliegue.", - "cancel": "Cancelar", - "saved": "Gasto guardado.", - "amountInvalid": "Introduzca un importe como 42.50.", - "photoDenied": "Permita el acceso a la cámara o a las fotos para adjuntar un recibo." } }, "protocols": { @@ -1662,6 +1737,7 @@ "background_location": "Ubicación en segundo plano", "contact_us": "Contáctanos", "current_unit": "Unidad Actual", + "custom": "Personalizado", "english": "Inglés", "enter_password": "Introduce tu contraseña", "enter_server_url": "Introduce la URL de la API de Resgrid (ej: https://api.resgrid.com)", @@ -1772,6 +1848,7 @@ "station_destination_enabled": "Puede responder a estaciones", "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Estaciones", + "status_queued": "Estado guardado en este dispositivo. Se enviará automáticamente.", "status_saved_successfully": "¡Estado guardado exitosamente!" }, "tabs": { @@ -1903,80 +1980,5 @@ "unknown": "Desconocido" } }, - "welcome": "Bienvenido al sitio de la aplicación Resgrid Unit", - "inventory": { - "title": "Inventario", - "titleFor": "Inventario · {{name}}", - "selectUnit": "Seleccione primero su unidad.", - "unnamedLocation": "Compartimento", - "unnamedItem": "Artículo", - "countName": "Revisión {{name}} {{date}}", - "notMigrated": "El inventario se está configurando para su organización.", - "disabled": "El inventario está desactivado para su organización.", - "noLocations": "Esta unidad no tiene inventario asignado.", - "startCount": "Contar el inventario", - "wholeUnit": "Unidad completa: {{name}}", - "pickLocation": "Dónde contar", - "startHelp": "Cuente lo que realmente hay en la unidad. Las diferencias se registran al completar el recuento.", - "start": "Iniciar recuento", - "cannotCount": "Contar requiere el permiso de ajustar inventario en esta unidad.", - "counts": "Recuentos recientes", - "count": "Recuento de inventario", - "empty": "Aquí no hay nada.", - "serial": "N.º de serie {{serial}}", - "quantity": "× {{quantity}}", - "back": "Atrás", - "completeTitle": "¿Completar el recuento?", - "completeBody": "{{count}} artículos difieren de lo esperado. Completar registra las diferencias.", - "completeNoChanges": "Todo coincide con lo esperado.", - "cancel": "Cancelar", - "complete": "Completar", - "cancelTitle": "¿Cancelar este recuento?", - "cancelBody": "No se registrará nada de lo contado.", - "keep": "Seguir contando", - "cancelCount": "Cancelar recuento", - "progress": "{{counted}} de {{total}} contados", - "differences": "{{count}} diferencias", - "awaitingWitness": "Cambió un artículo controlado; una segunda persona debe atestiguar este recuento en la web.", - "completed": "Recuento completado.", - "expected": "Esperado {{quantity}} {{unit}}", - "present": "Presente", - "missing": "Falta", - "less": "Uno menos", - "more": "Uno más", - "matches": "Coincide", - "save": "Guardar progreso", - "countStatus": { - "0": "En curso", - "1": "Pendiente de testigo", - "2": "Completado", - "3": "Cancelado" - }, - "assetStatus": { - "0": "En servicio", - "1": "Entregado", - "2": "En reparación", - "3": "Dañado", - "4": "Perdido", - "5": "Consumido", - "6": "Retirado" - }, - "errors": { - "retry": "No se pudo conectar con el servidor. Inténtelo de nuevo.", - "locked": "Desbloquee los datos protegidos para continuar.", - "PermissionRequired": "No tiene permiso para hacer esto.", - "MembershipRequired": "No es un miembro activo de esta organización.", - "InventoryDisabled": "El inventario está desactivado para su organización.", - "MigrationRequired": "El inventario se está configurando para su organización.", - "CountScopeTooLarge": "Demasiados artículos para un recuento. Cuente un compartimento cada vez.", - "CountEmpty": "Aquí no hay nada que contar.", - "CountSnapshotChanged": "El inventario de esta unidad cambió mientras contaba. Cancele el recuento y empiece de nuevo.", - "CountStateConflict": "Este recuento cambió en otro dispositivo. Vuelva a abrirlo e inténtelo de nuevo.", - "ReturnAssetFirst": "Un artículo marcado como faltante sigue entregado a alguien. Devuélvalo primero en la web.", - "ProtectedDataRequired": "Desbloquee los datos protegidos para continuar.", - "CountAlreadyExists": "Este recuento ya existe.", - "LocationUnavailable": "Esa ubicación no está disponible.", - "InvalidCount": "Revise los valores contados e inténtelo de nuevo." - } - } + "welcome": "Bienvenido al sitio de la aplicación Resgrid Unit" } diff --git a/src/translations/fr.json b/src/translations/fr.json index f9da7f36..dde0dab6 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -716,6 +716,7 @@ "address": "Adresse", "bluesky": "Bluesky", "cancel": "Annuler", + "categoryLabel": "Catégorie : {{name}}", "cellPhone": "Téléphone portable", "city": "Ville", "cityState": "Ville et État", @@ -729,6 +730,7 @@ "contactNotesLoading": "Chargement des notes du contact...", "contactType": "Type de contact", "countryId": "ID du pays", + "customFields": "Champs personnalisés", "delete": "Supprimer", "deleteConfirm": "Êtes-vous sûr de vouloir supprimer ce contact ?", "deleteSuccess": "Contact supprimé avec succès", @@ -753,6 +755,7 @@ "share_unavailable": "Le partage n'est pas disponible sur cet appareil." }, "formError": "Veuillez corriger les erreurs dans le formulaire", + "hasGeofence": "Ce lieu a un géorepérage cartographié.", "homePhone": "Téléphone domicile", "identification": "Identification", "important": "Marquer comme important", @@ -762,6 +765,7 @@ "linkedin": "LinkedIn", "locationCoordinates": "Coordonnées de localisation", "locationInformation": "Informations de localisation", + "mailingAddress": "Adresse postale", "mastodon": "Mastodon", "mobile": "Mobile", "name": "Nom", @@ -773,6 +777,7 @@ "otherInfo": "Autres informations", "person": "Personne", "phone": "Téléphone", + "physicalAddress": "Adresse physique", "preplan": { "access": "Accès", "access_notes": "Notes d'accès", @@ -838,13 +843,8 @@ "unknown_person": "Personne inconnue", "visibility": "Visibilité", "website": "Site web", - "zip": "Code postal", "withheld": "{{count}} informations protégées sont masquées.", - "categoryLabel": "Catégorie : {{name}}", - "physicalAddress": "Adresse physique", - "mailingAddress": "Adresse postale", - "hasGeofence": "Ce lieu a un géorepérage cartographié.", - "customFields": "Champs personnalisés" + "zip": "Code postal" }, "data_protection": { "conceal": "Masquer à nouveau", @@ -935,6 +935,81 @@ "send_message": "Message", "tab_title": "Commandement" }, + "inventory": { + "assetStatus": { + "0": "En service", + "1": "Remis", + "2": "En réparation", + "3": "Endommagé", + "4": "Perdu", + "5": "Consommé", + "6": "Retiré" + }, + "awaitingWitness": "Un article contrôlé a changé ; une seconde personne doit attester ce comptage sur le web.", + "back": "Retour", + "cancel": "Annuler", + "cancelBody": "Rien de ce qui a été compté n'est enregistré.", + "cancelCount": "Annuler le comptage", + "cancelTitle": "Annuler ce comptage ?", + "cannotCount": "Le comptage nécessite l'autorisation d'ajuster l'inventaire sur ce véhicule.", + "complete": "Terminer", + "completeBody": "{{count}} articles diffèrent de l'attendu. Terminer enregistre les écarts.", + "completeNoChanges": "Tout correspond à l'attendu.", + "completeTitle": "Terminer le comptage ?", + "completed": "Comptage terminé.", + "count": "Comptage d'inventaire", + "countName": "Contrôle {{name}} {{date}}", + "countStatus": { + "0": "En cours", + "1": "En attente de témoin", + "2": "Terminé", + "3": "Annulé" + }, + "counts": "Comptages récents", + "differences": "{{count}} écarts", + "disabled": "L'inventaire est désactivé pour votre organisation.", + "empty": "Rien ici.", + "errors": { + "CountAlreadyExists": "Ce comptage existe déjà.", + "CountEmpty": "Il n'y a rien à compter ici.", + "CountScopeTooLarge": "Trop d'articles pour un comptage. Comptez un coffre à la fois.", + "CountSnapshotChanged": "L'inventaire de ce véhicule a changé pendant le comptage. Annulez et recommencez.", + "CountStateConflict": "Ce comptage a changé sur un autre appareil. Rouvrez-le et réessayez.", + "InvalidCount": "Vérifiez les valeurs comptées et réessayez.", + "InventoryDisabled": "L'inventaire est désactivé pour votre organisation.", + "LocationUnavailable": "Cet emplacement n'est pas disponible.", + "MembershipRequired": "Vous n'êtes pas membre actif de cette organisation.", + "MigrationRequired": "L'inventaire est en cours de configuration pour votre organisation.", + "PermissionRequired": "Vous n'avez pas l'autorisation de faire cela.", + "ProtectedDataRequired": "Déverrouillez les données protégées pour continuer.", + "ReturnAssetFirst": "Un article marqué manquant est encore remis à quelqu'un. Rendez-le d'abord sur le web.", + "locked": "Déverrouillez les données protégées pour continuer.", + "retry": "Impossible de joindre le serveur. Réessayez." + }, + "expected": "Attendu {{quantity}} {{unit}}", + "keep": "Continuer le comptage", + "less": "Un de moins", + "matches": "Conforme", + "missing": "Manquant", + "more": "Un de plus", + "noLocations": "Aucun inventaire n'est affecté à ce véhicule.", + "notMigrated": "L'inventaire est en cours de configuration pour votre organisation.", + "pickLocation": "Où compter", + "present": "Présent", + "progress": "{{counted}} sur {{total}} comptés", + "quantity": "× {{quantity}}", + "save": "Enregistrer la progression", + "selectUnit": "Sélectionnez d'abord votre véhicule.", + "serial": "N° de série {{serial}}", + "start": "Commencer le comptage", + "startCount": "Compter l'inventaire", + "startHelp": "Comptez ce qui est réellement sur le véhicule. Les écarts sont enregistrés à la fin du comptage.", + "title": "Inventaire", + "titleFor": "Inventaire · {{name}}", + "unnamedItem": "Article", + "unnamedLocation": "Coffre", + "wholeUnit": "Véhicule entier : {{name}}" + }, "livekit": { "audio_devices": "Appareils audio", "audio_settings": "Paramètres audio", @@ -1064,14 +1139,17 @@ "title": "Notes" }, "notifications": { + "additional_info": "Informations supplémentaires", "confirm_delete_message_count": "Voulez-vous vraiment supprimer {{count}} notifications ? Cette action est irréversible.", "confirm_delete_message_one": "Voulez-vous vraiment supprimer cette notification ? Cette action est irréversible.", "confirm_delete_title": "Confirmer la suppression", "delete_selected": "Supprimer les notifications sélectionnées", "deselect_all": "Tout désélectionner", + "detail_title": "Notification", "empty": "Aucune mise à jour disponible", "enter_selection_mode": "Sélectionner des notifications", "open": "Ouvrir les notifications", + "open_reference": "Ouvrir", "remove_failed_count": "Échec de la suppression des notifications", "remove_failed_one": "Échec de la suppression de la notification", "removed_count": "{{count}} notifications supprimées", @@ -1080,11 +1158,8 @@ "selected_count": "{{count}} sélectionnées", "title": "Notifications", "unable_to_load": "Impossible de charger les notifications", - "detail_title": "Notification", - "additional_info": "Informations supplémentaires", "view_call": "Voir l'appel", - "view_chat": "Voir la discussion", - "open_reference": "Ouvrir" + "view_chat": "Voir la discussion" }, "onboarding": { "message": "Bienvenue sur l'application Resgrid Unit" @@ -1092,6 +1167,7 @@ "operations": { "all": "Tous", "back": "Retour", + "crewOf": "Équipage de {{names}}", "disabled": "Les déploiements ne sont pas activés pour ce service.", "entryType": { "0": "Déploiement", @@ -1100,18 +1176,49 @@ }, "errors": { "denied": "Vous n'avez pas accès à ce déploiement.", + "deployments_attachment_too_large": "Le reçu est trop volumineux.", + "deployments_closed": "Ce déploiement est clôturé.", "disabled": "Cette fonction est désactivée pour votre service.", + "expenses_receipt_invalid": "Le reçu n'a pas pu être lu.", "locked": "Déverrouillez les données protégées pour continuer.", "retry": "Serveur injoignable. Réessayez.", - "validation": "Le serveur a refusé la modification.", - "timereports_subject_covered": "Cet équipage ou cette personne a déjà du temps sur un autre rapport ce jour-là.", "timereports_date_exists": "Un rapport existe déjà pour cet équipage ou cette personne ce jour-là.", - "timereports_scope_invalid": "Cet équipage ou cette personne n'est pas actif sur ce déploiement.", "timereports_locked": "Ce rapport ne peut plus être modifié.", + "timereports_scope_invalid": "Cet équipage ou cette personne n'est pas actif sur ce déploiement.", + "timereports_subject_covered": "Cet équipage ou cette personne a déjà du temps sur un autre rapport ce jour-là.", "timereports_time_invalid": "Saisissez les heures au format HH:mm.", - "deployments_closed": "Ce déploiement est clôturé.", - "expenses_receipt_invalid": "Le reçu n'a pas pu être lu.", - "deployments_attachment_too_large": "Le reçu est trop volumineux." + "validation": "Le serveur a refusé la modification." + }, + "expenses": { + "add": "Nouvelle dépense", + "amount": "Montant ({{currency}})", + "amountInvalid": "Saisissez un montant comme 42.50.", + "cancel": "Annuler", + "choosePhoto": "Choisir une photo", + "city": "Ville", + "description": "Description", + "hasReceipt": "reçu", + "noneToday": "Aucune dépense ce jour-là.", + "otherDays": "Autres jours", + "photoDenied": "Autorisez l'accès à l'appareil photo ou aux photos pour joindre un reçu.", + "receiptAttached": "Photo du reçu jointe.", + "remove": "Supprimer", + "removeBody": "Cela supprime la dépense et son reçu du déploiement.", + "removeTitle": "Supprimer la dépense ?", + "save": "Ajouter la dépense", + "saved": "Dépense enregistrée.", + "takePhoto": "Prendre une photo", + "title": "Dépenses", + "type": { + "0": "Indemnité de repas", + "1": "Hébergement", + "2": "Hébergement privé", + "3": "Traversier", + "4": "Carburant", + "5": "Réapprovisionnement", + "6": "Autre" + }, + "typeLabel": "Type de dépense" }, "financeMode": { "0": "Opérationnel", @@ -1119,18 +1226,18 @@ "2": "Facturable" }, "issues": { + "break_rule": "Une longue vacation n'a aucune pause enregistrée.", "end_before_start": "L'heure de fin doit être après l'heure de début.", + "entries_overlap": "Deux entrées de la même personne ou unité se chevauchent.", + "long_travel": "Plus de déplacement qu'autorisé en une journée ; une approbation est nécessaire.", "missing_subject": "Choisissez à qui ou à quoi correspond cette entrée.", "missing_time": "Saisissez une heure de début et de fin.", + "no_entries": "Ajoutez au moins une entrée.", + "outside_report_date": "Cette entrée tombe en dehors de la date du rapport.", "overlap": "Cette entrée chevauche une autre pour la même personne ou unité.", - "subject_outside_report_scope": "Cette entrée concerne quelqu'un hors de l'équipage ou de la personne de ce rapport.", - "subject_on_other_report": "Cette personne ou unité a déjà du temps sur un autre rapport le même jour.", "subject_not_on_roster": "Cette entrée concerne quelqu'un qui n'est pas dans l'effectif.", - "entries_overlap": "Deux entrées de la même personne ou unité se chevauchent.", - "no_entries": "Ajoutez au moins une entrée.", - "break_rule": "Une longue vacation n'a aucune pause enregistrée.", - "long_travel": "Plus de déplacement qu'autorisé en une journée ; une approbation est nécessaire.", - "outside_report_date": "Cette entrée tombe en dehors de la date du rapport." + "subject_on_other_report": "Cette personne ou unité a déjà du temps sur un autre rapport le même jour.", + "subject_outside_report_scope": "Cette entrée concerne quelqu'un hors de l'équipage ou de la personne de ce rapport." }, "mars": { "counts": "{{errors}} erreurs · {{warnings}} avertissements", @@ -1144,6 +1251,7 @@ "title": "F-42", "validate": "Valider" }, + "nextDay": "Jour suivant", "noNumbers": "Aucun numéro d'incident ou de demande", "none": "Aucun déploiement.", "open": "Ouverts", @@ -1153,6 +1261,7 @@ "2": "Incident", "3": "Retour" }, + "previousDay": "Jour précédent", "reportStatus": { "0": "Brouillon", "1": "Soumis", @@ -1161,6 +1270,22 @@ "4": "Annulé" }, "roster": "{{units}} unités · {{personnel}} personnes", + "scope": { + "crew": "Équipage : {{name}}", + "deployment": "Tout le déploiement", + "individual": "{{name}}", + "mine": "Mon temps" + }, + "sign": { + "asCrewBoss": "Signer en tant que chef d'équipage", + "crewBossPending": "Pas encore signé par le chef d'équipage.", + "crewBossSigned": "Signé par le chef d'équipage {{when}}", + "customerName": "Nom du signataire de l'organisme", + "customerPending": "Aucune signature de l'organisme enregistrée.", + "customerSigned": "Signataire de l'organisme : {{name}}", + "recordCustomer": "Enregistrer la signature de l'organisme", + "title": "Signatures" + }, "status": { "0": "Planifié", "1": "Attente", @@ -1172,35 +1297,35 @@ "time": { "accommodation": "Hébergement fourni par l'agence", "addEntry": "Ajouter une entrée", + "applyToCrew": "Appliquer à l'équipage", + "approve": "Approuver le rapport", + "awaitingApproval": "En attente d'approbation ({{count}})", + "coveredBy": "Ce temps figure déjà sur le rapport #{{number}} de ce jour.", + "crewReport": "Rapport de temps d'équipage #{{number}} · {{name}}", + "dayReports": "Rapports de ce jour", "from": "De", "hours": "{{hours}} h", + "individualReport": "Rapport de temps #{{number}} · {{name}}", "meals": "Repas fournis par l'agence", + "nextDay": "Se termine le lendemain", + "noCrewReport": "Aucun rapport d'équipage pour {{name}} ce jour-là.", "noEntries": "Aucune entrée pour le moment.", "noReport": "Aucun rapport de temps pour ce jour.", + "notOnRoster": "Vous ne faites pas partie de l'effectif ni d'un équipage de ce déploiement.", "notes": "Notes", + "readOnly": "Vous pouvez consulter ce rapport mais pas le modifier.", "remove": "Supprimer", "report": "Rapport #{{number}}", "save": "Enregistrer les entrées", "start": "Commencer le rapport de temps", + "startCrew": "Commencer le rapport d'équipage", "subject": "Qui ou quoi", "submit": "Soumettre le rapport", "title": "Temps", "to": "À", "total": "Total {{hours}} h", "type": "Type d'entrée", - "unpaidBreak": "Pause non payée (min)", - "crewReport": "Rapport de temps d'équipage #{{number}} · {{name}}", - "individualReport": "Rapport de temps #{{number}} · {{name}}", - "noCrewReport": "Aucun rapport d'équipage pour {{name}} ce jour-là.", - "coveredBy": "Ce temps figure déjà sur le rapport #{{number}} de ce jour.", - "startCrew": "Commencer le rapport d'équipage", - "readOnly": "Vous pouvez consulter ce rapport mais pas le modifier.", - "nextDay": "Se termine le lendemain", - "applyToCrew": "Appliquer à l'équipage", - "approve": "Approuver le rapport", - "dayReports": "Rapports de ce jour", - "awaitingApproval": "En attente d'approbation ({{count}})", - "notOnRoster": "Vous ne faites pas partie de l'effectif ni d'un équipage de ce déploiement." + "unpaidBreak": "Pause non payée (min)" }, "title": "Déploiements", "usage": { @@ -1214,56 +1339,6 @@ "startOdometer": "Compteur début", "title": "Utilisation", "unit": "Unité" - }, - "crewOf": "Équipage de {{names}}", - "previousDay": "Jour précédent", - "nextDay": "Jour suivant", - "scope": { - "deployment": "Tout le déploiement", - "mine": "Mon temps", - "crew": "Équipage : {{name}}", - "individual": "{{name}}" - }, - "sign": { - "title": "Signatures", - "crewBossSigned": "Signé par le chef d'équipage {{when}}", - "crewBossPending": "Pas encore signé par le chef d'équipage.", - "customerSigned": "Signataire de l'organisme : {{name}}", - "customerPending": "Aucune signature de l'organisme enregistrée.", - "asCrewBoss": "Signer en tant que chef d'équipage", - "customerName": "Nom du signataire de l'organisme", - "recordCustomer": "Enregistrer la signature de l'organisme" - }, - "expenses": { - "title": "Dépenses", - "type": { - "0": "Indemnité de repas", - "1": "Hébergement", - "2": "Hébergement privé", - "3": "Traversier", - "4": "Carburant", - "5": "Réapprovisionnement", - "6": "Autre" - }, - "typeLabel": "Type de dépense", - "amount": "Montant ({{currency}})", - "description": "Description", - "city": "Ville", - "takePhoto": "Prendre une photo", - "choosePhoto": "Choisir une photo", - "receiptAttached": "Photo du reçu jointe.", - "save": "Ajouter la dépense", - "add": "Nouvelle dépense", - "noneToday": "Aucune dépense ce jour-là.", - "otherDays": "Autres jours", - "hasReceipt": "reçu", - "remove": "Supprimer", - "removeTitle": "Supprimer la dépense ?", - "removeBody": "Cela supprime la dépense et son reçu du déploiement.", - "cancel": "Annuler", - "saved": "Dépense enregistrée.", - "amountInvalid": "Saisissez un montant comme 42.50.", - "photoDenied": "Autorisez l'accès à l'appareil photo ou aux photos pour joindre un reçu." } }, "protocols": { @@ -1662,6 +1737,7 @@ "background_location": "Localisation en arrière-plan", "contact_us": "Nous contacter", "current_unit": "Unité actuelle", + "custom": "Personnalisé", "english": "Anglais", "enter_password": "Saisir votre mot de passe", "enter_server_url": "Saisir l'URL de l'API Resgrid (ex. https://api.resgrid.com)", @@ -1772,6 +1848,7 @@ "station_destination_enabled": "Peut répondre aux stations", "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Stations", + "status_queued": "Statut enregistré sur cet appareil. Il sera envoyé automatiquement.", "status_saved_successfully": "Statut enregistré avec succès !" }, "tabs": { @@ -1903,80 +1980,5 @@ "unknown": "Inconnu" } }, - "welcome": "Bienvenue sur l'application Resgrid Unit", - "inventory": { - "title": "Inventaire", - "titleFor": "Inventaire · {{name}}", - "selectUnit": "Sélectionnez d'abord votre véhicule.", - "unnamedLocation": "Coffre", - "unnamedItem": "Article", - "countName": "Contrôle {{name}} {{date}}", - "notMigrated": "L'inventaire est en cours de configuration pour votre organisation.", - "disabled": "L'inventaire est désactivé pour votre organisation.", - "noLocations": "Aucun inventaire n'est affecté à ce véhicule.", - "startCount": "Compter l'inventaire", - "wholeUnit": "Véhicule entier : {{name}}", - "pickLocation": "Où compter", - "startHelp": "Comptez ce qui est réellement sur le véhicule. Les écarts sont enregistrés à la fin du comptage.", - "start": "Commencer le comptage", - "cannotCount": "Le comptage nécessite l'autorisation d'ajuster l'inventaire sur ce véhicule.", - "counts": "Comptages récents", - "count": "Comptage d'inventaire", - "empty": "Rien ici.", - "serial": "N° de série {{serial}}", - "quantity": "× {{quantity}}", - "back": "Retour", - "completeTitle": "Terminer le comptage ?", - "completeBody": "{{count}} articles diffèrent de l'attendu. Terminer enregistre les écarts.", - "completeNoChanges": "Tout correspond à l'attendu.", - "cancel": "Annuler", - "complete": "Terminer", - "cancelTitle": "Annuler ce comptage ?", - "cancelBody": "Rien de ce qui a été compté n'est enregistré.", - "keep": "Continuer le comptage", - "cancelCount": "Annuler le comptage", - "progress": "{{counted}} sur {{total}} comptés", - "differences": "{{count}} écarts", - "awaitingWitness": "Un article contrôlé a changé ; une seconde personne doit attester ce comptage sur le web.", - "completed": "Comptage terminé.", - "expected": "Attendu {{quantity}} {{unit}}", - "present": "Présent", - "missing": "Manquant", - "less": "Un de moins", - "more": "Un de plus", - "matches": "Conforme", - "save": "Enregistrer la progression", - "countStatus": { - "0": "En cours", - "1": "En attente de témoin", - "2": "Terminé", - "3": "Annulé" - }, - "assetStatus": { - "0": "En service", - "1": "Remis", - "2": "En réparation", - "3": "Endommagé", - "4": "Perdu", - "5": "Consommé", - "6": "Retiré" - }, - "errors": { - "retry": "Impossible de joindre le serveur. Réessayez.", - "locked": "Déverrouillez les données protégées pour continuer.", - "PermissionRequired": "Vous n'avez pas l'autorisation de faire cela.", - "MembershipRequired": "Vous n'êtes pas membre actif de cette organisation.", - "InventoryDisabled": "L'inventaire est désactivé pour votre organisation.", - "MigrationRequired": "L'inventaire est en cours de configuration pour votre organisation.", - "CountScopeTooLarge": "Trop d'articles pour un comptage. Comptez un coffre à la fois.", - "CountEmpty": "Il n'y a rien à compter ici.", - "CountSnapshotChanged": "L'inventaire de ce véhicule a changé pendant le comptage. Annulez et recommencez.", - "CountStateConflict": "Ce comptage a changé sur un autre appareil. Rouvrez-le et réessayez.", - "ReturnAssetFirst": "Un article marqué manquant est encore remis à quelqu'un. Rendez-le d'abord sur le web.", - "ProtectedDataRequired": "Déverrouillez les données protégées pour continuer.", - "CountAlreadyExists": "Ce comptage existe déjà.", - "LocationUnavailable": "Cet emplacement n'est pas disponible.", - "InvalidCount": "Vérifiez les valeurs comptées et réessayez." - } - } + "welcome": "Bienvenue sur l'application Resgrid Unit" } diff --git a/src/translations/it.json b/src/translations/it.json index e52cf72d..c30729eb 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -716,6 +716,7 @@ "address": "Indirizzo", "bluesky": "Bluesky", "cancel": "Annulla", + "categoryLabel": "Categoria: {{name}}", "cellPhone": "Cellulare", "city": "Città", "cityState": "Città e provincia", @@ -729,6 +730,7 @@ "contactNotesLoading": "Caricamento note contatto...", "contactType": "Tipo contatto", "countryId": "ID paese", + "customFields": "Campi personalizzati", "delete": "Elimina", "deleteConfirm": "Sei sicuro di voler eliminare questo contatto?", "deleteSuccess": "Contatto eliminato con successo", @@ -753,6 +755,7 @@ "share_unavailable": "La condivisione non è disponibile su questo dispositivo." }, "formError": "Correggere gli errori nel modulo", + "hasGeofence": "Questo luogo ha un geofence mappato.", "homePhone": "Telefono casa", "identification": "Identificazione", "important": "Segna come importante", @@ -762,6 +765,7 @@ "linkedin": "LinkedIn", "locationCoordinates": "Coordinate posizione", "locationInformation": "Informazioni posizione", + "mailingAddress": "Indirizzo postale", "mastodon": "Mastodon", "mobile": "Mobile", "name": "Nome", @@ -773,6 +777,7 @@ "otherInfo": "Altre informazioni", "person": "Persona", "phone": "Telefono", + "physicalAddress": "Indirizzo fisico", "preplan": { "access": "Accesso", "access_notes": "Note di accesso", @@ -838,13 +843,8 @@ "unknown_person": "Persona sconosciuta", "visibility": "Visibilità", "website": "Sito web", - "zip": "CAP", "withheld": "{{count}} dettagli protetti sono nascosti.", - "categoryLabel": "Categoria: {{name}}", - "physicalAddress": "Indirizzo fisico", - "mailingAddress": "Indirizzo postale", - "hasGeofence": "Questo luogo ha un geofence mappato.", - "customFields": "Campi personalizzati" + "zip": "CAP" }, "data_protection": { "conceal": "Nascondi di nuovo", @@ -935,6 +935,81 @@ "send_message": "Message", "tab_title": "Comando" }, + "inventory": { + "assetStatus": { + "0": "In servizio", + "1": "Consegnato", + "2": "In riparazione", + "3": "Danneggiato", + "4": "Perso", + "5": "Consumato", + "6": "Dismesso" + }, + "awaitingWitness": "È cambiato un articolo controllato; una seconda persona deve attestare il conteggio sul web.", + "back": "Indietro", + "cancel": "Annulla", + "cancelBody": "Nulla di quanto contato verrà registrato.", + "cancelCount": "Annulla conteggio", + "cancelTitle": "Annullare questo conteggio?", + "cannotCount": "Per contare serve il permesso di rettifica dell'inventario su questo mezzo.", + "complete": "Completa", + "completeBody": "{{count}} articoli differiscono dal previsto. Completando si registrano le differenze.", + "completeNoChanges": "Tutto corrisponde al previsto.", + "completeTitle": "Completare il conteggio?", + "completed": "Conteggio completato.", + "count": "Conteggio inventario", + "countName": "Controllo {{name}} {{date}}", + "countStatus": { + "0": "In corso", + "1": "In attesa di testimone", + "2": "Completato", + "3": "Annullato" + }, + "counts": "Conteggi recenti", + "differences": "{{count}} differenze", + "disabled": "L'inventario è disattivato per la tua organizzazione.", + "empty": "Qui non c'è niente.", + "errors": { + "CountAlreadyExists": "Questo conteggio esiste già.", + "CountEmpty": "Non c'è nulla da contare qui.", + "CountScopeTooLarge": "Troppi articoli per un solo conteggio. Conta un vano alla volta.", + "CountSnapshotChanged": "L'inventario del mezzo è cambiato durante il conteggio. Annulla e ricomincia.", + "CountStateConflict": "Il conteggio è cambiato su un altro dispositivo. Riaprilo e riprova.", + "InvalidCount": "Controlla i valori contati e riprova.", + "InventoryDisabled": "L'inventario è disattivato per la tua organizzazione.", + "LocationUnavailable": "Quella posizione non è disponibile.", + "MembershipRequired": "Non sei un membro attivo di questa organizzazione.", + "MigrationRequired": "L'inventario è in fase di configurazione per la tua organizzazione.", + "PermissionRequired": "Non hai il permesso per farlo.", + "ProtectedDataRequired": "Sblocca i dati protetti per continuare.", + "ReturnAssetFirst": "Un articolo segnato come mancante è ancora consegnato a qualcuno. Restituiscilo prima sul web.", + "locked": "Sblocca i dati protetti per continuare.", + "retry": "Impossibile raggiungere il server. Riprova." + }, + "expected": "Previsto {{quantity}} {{unit}}", + "keep": "Continua a contare", + "less": "Uno in meno", + "matches": "Corrisponde", + "missing": "Mancante", + "more": "Uno in più", + "noLocations": "A questo mezzo non è assegnato alcun inventario.", + "notMigrated": "L'inventario è in fase di configurazione per la tua organizzazione.", + "pickLocation": "Dove contare", + "present": "Presente", + "progress": "{{counted}} di {{total}} contati", + "quantity": "× {{quantity}}", + "save": "Salva avanzamento", + "selectUnit": "Seleziona prima il tuo mezzo.", + "serial": "Matricola {{serial}}", + "start": "Avvia conteggio", + "startCount": "Conta l'inventario", + "startHelp": "Conta ciò che è davvero sul mezzo. Le differenze vengono registrate al completamento.", + "title": "Inventario", + "titleFor": "Inventario · {{name}}", + "unnamedItem": "Articolo", + "unnamedLocation": "Vano", + "wholeUnit": "Intero mezzo: {{name}}" + }, "livekit": { "audio_devices": "Dispositivi audio", "audio_settings": "Impostazioni audio", @@ -1064,14 +1139,17 @@ "title": "Note" }, "notifications": { + "additional_info": "Informazioni aggiuntive", "confirm_delete_message_count": "Vuoi davvero eliminare {{count}} notifiche? Questa azione non può essere annullata.", "confirm_delete_message_one": "Vuoi davvero eliminare questa notifica? Questa azione non può essere annullata.", "confirm_delete_title": "Conferma l'eliminazione", "delete_selected": "Elimina le notifiche selezionate", "deselect_all": "Deseleziona tutto", + "detail_title": "Notifica", "empty": "Nessun aggiornamento disponibile", "enter_selection_mode": "Seleziona notifiche", "open": "Apri le notifiche", + "open_reference": "Apri", "remove_failed_count": "Impossibile eliminare le notifiche", "remove_failed_one": "Impossibile eliminare la notifica", "removed_count": "{{count}} notifiche eliminate", @@ -1080,11 +1158,8 @@ "selected_count": "{{count}} selezionate", "title": "Notifiche", "unable_to_load": "Impossibile caricare le notifiche", - "detail_title": "Notifica", - "additional_info": "Informazioni aggiuntive", "view_call": "Visualizza chiamata", - "view_chat": "Visualizza chat", - "open_reference": "Apri" + "view_chat": "Visualizza chat" }, "onboarding": { "message": "Benvenuto nell'app Resgrid Unit" @@ -1092,6 +1167,7 @@ "operations": { "all": "Tutti", "back": "Indietro", + "crewOf": "Equipaggio di {{names}}", "disabled": "I dispiegamenti non sono abilitati per questo dipartimento.", "entryType": { "0": "Dispiegamento", @@ -1100,18 +1176,49 @@ }, "errors": { "denied": "Non hai accesso a questo dispiegamento.", + "deployments_attachment_too_large": "La ricevuta è troppo grande.", + "deployments_closed": "Questo dispiegamento è chiuso.", "disabled": "Questa funzione è disattivata per il tuo dipartimento.", + "expenses_receipt_invalid": "Impossibile leggere la ricevuta.", "locked": "Sblocca i dati protetti per continuare.", "retry": "Impossibile raggiungere il server. Riprova.", - "validation": "Il server ha rifiutato la modifica.", - "timereports_subject_covered": "Questo equipaggio o questa persona ha già ore su un altro rapporto di questo giorno.", "timereports_date_exists": "Esiste già un rapporto per questo equipaggio o persona in quel giorno.", - "timereports_scope_invalid": "Questo equipaggio o questa persona non è attivo in questo dispiegamento.", "timereports_locked": "Questo rapporto non può più essere modificato.", + "timereports_scope_invalid": "Questo equipaggio o questa persona non è attivo in questo dispiegamento.", + "timereports_subject_covered": "Questo equipaggio o questa persona ha già ore su un altro rapporto di questo giorno.", "timereports_time_invalid": "Inserisci gli orari come HH:mm.", - "deployments_closed": "Questo dispiegamento è chiuso.", - "expenses_receipt_invalid": "Impossibile leggere la ricevuta.", - "deployments_attachment_too_large": "La ricevuta è troppo grande." + "validation": "Il server ha rifiutato la modifica." + }, + "expenses": { + "add": "Nuova spesa", + "amount": "Importo ({{currency}})", + "amountInvalid": "Inserisci un importo come 42.50.", + "cancel": "Annulla", + "choosePhoto": "Scegli foto", + "city": "Città", + "description": "Descrizione", + "hasReceipt": "ricevuta", + "noneToday": "Nessuna spesa in questo giorno.", + "otherDays": "Altri giorni", + "photoDenied": "Consenti l'accesso alla fotocamera o alle foto per allegare una ricevuta.", + "receiptAttached": "Foto della ricevuta allegata.", + "remove": "Rimuovi", + "removeBody": "La spesa e la sua ricevuta vengono rimosse dal dispiegamento.", + "removeTitle": "Rimuovere la spesa?", + "save": "Aggiungi spesa", + "saved": "Spesa salvata.", + "takePhoto": "Scatta foto", + "title": "Spese", + "type": { + "0": "Diaria pasti", + "1": "Alloggio", + "2": "Alloggio privato", + "3": "Traghetto", + "4": "Carburante", + "5": "Reintegro materiali", + "6": "Altro" + }, + "typeLabel": "Tipo di spesa" }, "financeMode": { "0": "Operativo", @@ -1119,18 +1226,18 @@ "2": "Fatturabile" }, "issues": { + "break_rule": "Un turno lungo non ha pause registrate.", "end_before_start": "L'ora di fine deve essere successiva a quella di inizio.", + "entries_overlap": "Due voci della stessa persona o unità si sovrappongono.", + "long_travel": "Più viaggio di quanto consentito in un giorno; serve approvazione.", "missing_subject": "Scegli a chi o a cosa si riferisce questa voce.", "missing_time": "Inserisci un'ora di inizio e di fine.", + "no_entries": "Aggiungi almeno una voce.", + "outside_report_date": "Questa voce è fuori dalla data del rapporto.", "overlap": "Questa voce si sovrappone a un'altra per la stessa persona o unità.", - "subject_outside_report_scope": "Questa voce riguarda qualcuno al di fuori dell'equipaggio o della persona di questo rapporto.", - "subject_on_other_report": "Questa persona o unità ha già ore su un altro rapporto dello stesso giorno.", "subject_not_on_roster": "Questa voce riguarda qualcuno che non è nell'organico.", - "entries_overlap": "Due voci della stessa persona o unità si sovrappongono.", - "no_entries": "Aggiungi almeno una voce.", - "break_rule": "Un turno lungo non ha pause registrate.", - "long_travel": "Più viaggio di quanto consentito in un giorno; serve approvazione.", - "outside_report_date": "Questa voce è fuori dalla data del rapporto." + "subject_on_other_report": "Questa persona o unità ha già ore su un altro rapporto dello stesso giorno.", + "subject_outside_report_scope": "Questa voce riguarda qualcuno al di fuori dell'equipaggio o della persona di questo rapporto." }, "mars": { "counts": "{{errors}} errori · {{warnings}} avvisi", @@ -1144,6 +1251,7 @@ "title": "F-42", "validate": "Convalida" }, + "nextDay": "Giorno successivo", "noNumbers": "Nessun numero di incidente o richiesta", "none": "Nessun dispiegamento.", "open": "Aperti", @@ -1153,6 +1261,7 @@ "2": "Incidente", "3": "Rientro" }, + "previousDay": "Giorno precedente", "reportStatus": { "0": "Bozza", "1": "Inviato", @@ -1161,6 +1270,22 @@ "4": "Annullato" }, "roster": "{{units}} unità · {{personnel}} persone", + "scope": { + "crew": "Equipaggio: {{name}}", + "deployment": "Intero dispiegamento", + "individual": "{{name}}", + "mine": "Il mio tempo" + }, + "sign": { + "asCrewBoss": "Firma come capo equipaggio", + "crewBossPending": "Non ancora firmato dal capo equipaggio.", + "crewBossSigned": "Firmato dal capo equipaggio {{when}}", + "customerName": "Nome del firmatario dell'ente", + "customerPending": "Nessuna firma dell'ente registrata.", + "customerSigned": "Firmatario dell'ente: {{name}}", + "recordCustomer": "Registra la firma dell'ente", + "title": "Firme" + }, "status": { "0": "Pianificato", "1": "Attesa", @@ -1172,35 +1297,35 @@ "time": { "accommodation": "Alloggio fornito dall'agenzia", "addEntry": "Aggiungi voce", + "applyToCrew": "Applica all'equipaggio", + "approve": "Approva rapporto", + "awaitingApproval": "In attesa di approvazione ({{count}})", + "coveredBy": "Queste ore sono già nel rapporto #{{number}} di questo giorno.", + "crewReport": "Rapporto ore dell'equipaggio #{{number}} · {{name}}", + "dayReports": "Rapporti di questo giorno", "from": "Da", "hours": "{{hours}} h", + "individualReport": "Rapporto ore #{{number}} · {{name}}", "meals": "Pasti forniti dall'agenzia", + "nextDay": "Termina il giorno dopo", + "noCrewReport": "Nessun rapporto dell'equipaggio per {{name}} in questo giorno.", "noEntries": "Nessuna voce.", "noReport": "Nessun rapporto orario per questo giorno.", + "notOnRoster": "Non sei nell'organico né in un equipaggio di questo dispiegamento.", "notes": "Note", + "readOnly": "Puoi vedere questo rapporto ma non modificarlo.", "remove": "Rimuovi", "report": "Rapporto #{{number}}", "save": "Salva voci", "start": "Avvia rapporto orario", + "startCrew": "Avvia rapporto dell'equipaggio", "subject": "Chi o cosa", "submit": "Invia rapporto", "title": "Tempo", "to": "A", "total": "Totale {{hours}} h", "type": "Tipo di voce", - "unpaidBreak": "Pausa non retribuita (min)", - "crewReport": "Rapporto ore dell'equipaggio #{{number}} · {{name}}", - "individualReport": "Rapporto ore #{{number}} · {{name}}", - "noCrewReport": "Nessun rapporto dell'equipaggio per {{name}} in questo giorno.", - "coveredBy": "Queste ore sono già nel rapporto #{{number}} di questo giorno.", - "startCrew": "Avvia rapporto dell'equipaggio", - "readOnly": "Puoi vedere questo rapporto ma non modificarlo.", - "nextDay": "Termina il giorno dopo", - "applyToCrew": "Applica all'equipaggio", - "approve": "Approva rapporto", - "dayReports": "Rapporti di questo giorno", - "awaitingApproval": "In attesa di approvazione ({{count}})", - "notOnRoster": "Non sei nell'organico né in un equipaggio di questo dispiegamento." + "unpaidBreak": "Pausa non retribuita (min)" }, "title": "Dispiegamenti", "usage": { @@ -1214,56 +1339,6 @@ "startOdometer": "Contachilometri iniziale", "title": "Utilizzo", "unit": "Unità" - }, - "crewOf": "Equipaggio di {{names}}", - "previousDay": "Giorno precedente", - "nextDay": "Giorno successivo", - "scope": { - "deployment": "Intero dispiegamento", - "mine": "Il mio tempo", - "crew": "Equipaggio: {{name}}", - "individual": "{{name}}" - }, - "sign": { - "title": "Firme", - "crewBossSigned": "Firmato dal capo equipaggio {{when}}", - "crewBossPending": "Non ancora firmato dal capo equipaggio.", - "customerSigned": "Firmatario dell'ente: {{name}}", - "customerPending": "Nessuna firma dell'ente registrata.", - "asCrewBoss": "Firma come capo equipaggio", - "customerName": "Nome del firmatario dell'ente", - "recordCustomer": "Registra la firma dell'ente" - }, - "expenses": { - "title": "Spese", - "type": { - "0": "Diaria pasti", - "1": "Alloggio", - "2": "Alloggio privato", - "3": "Traghetto", - "4": "Carburante", - "5": "Reintegro materiali", - "6": "Altro" - }, - "typeLabel": "Tipo di spesa", - "amount": "Importo ({{currency}})", - "description": "Descrizione", - "city": "Città", - "takePhoto": "Scatta foto", - "choosePhoto": "Scegli foto", - "receiptAttached": "Foto della ricevuta allegata.", - "save": "Aggiungi spesa", - "add": "Nuova spesa", - "noneToday": "Nessuna spesa in questo giorno.", - "otherDays": "Altri giorni", - "hasReceipt": "ricevuta", - "remove": "Rimuovi", - "removeTitle": "Rimuovere la spesa?", - "removeBody": "La spesa e la sua ricevuta vengono rimosse dal dispiegamento.", - "cancel": "Annulla", - "saved": "Spesa salvata.", - "amountInvalid": "Inserisci un importo come 42.50.", - "photoDenied": "Consenti l'accesso alla fotocamera o alle foto per allegare una ricevuta." } }, "protocols": { @@ -1662,6 +1737,7 @@ "background_location": "Posizione in background", "contact_us": "Contattaci", "current_unit": "Unità corrente", + "custom": "Personalizzato", "english": "Inglese", "enter_password": "Inserisci la password", "enter_server_url": "Inserisci URL API Resgrid (es. https://api.resgrid.com)", @@ -1772,6 +1848,7 @@ "station_destination_enabled": "Può rispondere a stazioni", "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Stazioni", + "status_queued": "Stato salvato su questo dispositivo. Verrà inviato automaticamente.", "status_saved_successfully": "Stato salvato con successo!" }, "tabs": { @@ -1903,80 +1980,5 @@ "unknown": "Sconosciuto" } }, - "welcome": "Benvenuto nell'app Resgrid Unit", - "inventory": { - "title": "Inventario", - "titleFor": "Inventario · {{name}}", - "selectUnit": "Seleziona prima il tuo mezzo.", - "unnamedLocation": "Vano", - "unnamedItem": "Articolo", - "countName": "Controllo {{name}} {{date}}", - "notMigrated": "L'inventario è in fase di configurazione per la tua organizzazione.", - "disabled": "L'inventario è disattivato per la tua organizzazione.", - "noLocations": "A questo mezzo non è assegnato alcun inventario.", - "startCount": "Conta l'inventario", - "wholeUnit": "Intero mezzo: {{name}}", - "pickLocation": "Dove contare", - "startHelp": "Conta ciò che è davvero sul mezzo. Le differenze vengono registrate al completamento.", - "start": "Avvia conteggio", - "cannotCount": "Per contare serve il permesso di rettifica dell'inventario su questo mezzo.", - "counts": "Conteggi recenti", - "count": "Conteggio inventario", - "empty": "Qui non c'è niente.", - "serial": "Matricola {{serial}}", - "quantity": "× {{quantity}}", - "back": "Indietro", - "completeTitle": "Completare il conteggio?", - "completeBody": "{{count}} articoli differiscono dal previsto. Completando si registrano le differenze.", - "completeNoChanges": "Tutto corrisponde al previsto.", - "cancel": "Annulla", - "complete": "Completa", - "cancelTitle": "Annullare questo conteggio?", - "cancelBody": "Nulla di quanto contato verrà registrato.", - "keep": "Continua a contare", - "cancelCount": "Annulla conteggio", - "progress": "{{counted}} di {{total}} contati", - "differences": "{{count}} differenze", - "awaitingWitness": "È cambiato un articolo controllato; una seconda persona deve attestare il conteggio sul web.", - "completed": "Conteggio completato.", - "expected": "Previsto {{quantity}} {{unit}}", - "present": "Presente", - "missing": "Mancante", - "less": "Uno in meno", - "more": "Uno in più", - "matches": "Corrisponde", - "save": "Salva avanzamento", - "countStatus": { - "0": "In corso", - "1": "In attesa di testimone", - "2": "Completato", - "3": "Annullato" - }, - "assetStatus": { - "0": "In servizio", - "1": "Consegnato", - "2": "In riparazione", - "3": "Danneggiato", - "4": "Perso", - "5": "Consumato", - "6": "Dismesso" - }, - "errors": { - "retry": "Impossibile raggiungere il server. Riprova.", - "locked": "Sblocca i dati protetti per continuare.", - "PermissionRequired": "Non hai il permesso per farlo.", - "MembershipRequired": "Non sei un membro attivo di questa organizzazione.", - "InventoryDisabled": "L'inventario è disattivato per la tua organizzazione.", - "MigrationRequired": "L'inventario è in fase di configurazione per la tua organizzazione.", - "CountScopeTooLarge": "Troppi articoli per un solo conteggio. Conta un vano alla volta.", - "CountEmpty": "Non c'è nulla da contare qui.", - "CountSnapshotChanged": "L'inventario del mezzo è cambiato durante il conteggio. Annulla e ricomincia.", - "CountStateConflict": "Il conteggio è cambiato su un altro dispositivo. Riaprilo e riprova.", - "ReturnAssetFirst": "Un articolo segnato come mancante è ancora consegnato a qualcuno. Restituiscilo prima sul web.", - "ProtectedDataRequired": "Sblocca i dati protetti per continuare.", - "CountAlreadyExists": "Questo conteggio esiste già.", - "LocationUnavailable": "Quella posizione non è disponibile.", - "InvalidCount": "Controlla i valori contati e riprova." - } - } + "welcome": "Benvenuto nell'app Resgrid Unit" } diff --git a/src/translations/pl.json b/src/translations/pl.json index 716b8fbe..83920f7d 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -716,6 +716,7 @@ "address": "Adres", "bluesky": "Bluesky", "cancel": "Anuluj", + "categoryLabel": "Kategoria: {{name}}", "cellPhone": "Telefon komórkowy", "city": "Miasto", "cityState": "Miasto i województwo", @@ -729,6 +730,7 @@ "contactNotesLoading": "Ładowanie notatek kontaktu...", "contactType": "Typ kontaktu", "countryId": "ID kraju", + "customFields": "Pola niestandardowe", "delete": "Usuń", "deleteConfirm": "Czy na pewno chcesz usunąć ten kontakt?", "deleteSuccess": "Kontakt został pomyślnie usunięty", @@ -753,6 +755,7 @@ "share_unavailable": "Udostępnianie nie jest dostępne na tym urządzeniu." }, "formError": "Napraw błędy w formularzu", + "hasGeofence": "Ta lokalizacja ma wyznaczoną na mapie strefę geofence.", "homePhone": "Telefon domowy", "identification": "Identyfikacja", "important": "Oznacz jako ważne", @@ -762,6 +765,7 @@ "linkedin": "LinkedIn", "locationCoordinates": "Współrzędne lokalizacji", "locationInformation": "Informacje o lokalizacji", + "mailingAddress": "Adres korespondencyjny", "mastodon": "Mastodon", "mobile": "Komórka", "name": "Nazwa", @@ -773,6 +777,7 @@ "otherInfo": "Inne informacje", "person": "Osoba", "phone": "Telefon", + "physicalAddress": "Adres fizyczny", "preplan": { "access": "Dostęp", "access_notes": "Uwagi dotyczące dostępu", @@ -838,13 +843,8 @@ "unknown_person": "Nieznana osoba", "visibility": "Widoczność", "website": "Strona internetowa", - "zip": "Kod pocztowy", "withheld": "Ukryto {{count}} chronionych danych.", - "categoryLabel": "Kategoria: {{name}}", - "physicalAddress": "Adres fizyczny", - "mailingAddress": "Adres korespondencyjny", - "hasGeofence": "Ta lokalizacja ma wyznaczoną na mapie strefę geofence.", - "customFields": "Pola niestandardowe" + "zip": "Kod pocztowy" }, "data_protection": { "conceal": "Ukryj ponownie", @@ -935,6 +935,81 @@ "send_message": "Message", "tab_title": "Dowodzenie" }, + "inventory": { + "assetStatus": { + "0": "W użyciu", + "1": "Wydane", + "2": "W naprawie", + "3": "Uszkodzone", + "4": "Zgubione", + "5": "Zużyte", + "6": "Wycofane" + }, + "awaitingWitness": "Zmieniła się pozycja kontrolowana; druga osoba musi poświadczyć to liczenie w przeglądarce.", + "back": "Wstecz", + "cancel": "Anuluj", + "cancelBody": "Nic z policzonego nie zostanie zaksięgowane.", + "cancelCount": "Anuluj liczenie", + "cancelTitle": "Anulować to liczenie?", + "cannotCount": "Liczenie wymaga uprawnienia do korekty wyposażenia w tym pojeździe.", + "complete": "Zakończ", + "completeBody": "{{count}} pozycji różni się od oczekiwanych. Zakończenie zaksięguje różnice.", + "completeNoChanges": "Wszystko zgadza się z oczekiwanym.", + "completeTitle": "Zakończyć liczenie?", + "completed": "Liczenie zakończone.", + "count": "Liczenie wyposażenia", + "countName": "Kontrola {{name}} {{date}}", + "countStatus": { + "0": "W toku", + "1": "Oczekuje na świadka", + "2": "Zakończone", + "3": "Anulowane" + }, + "counts": "Ostatnie liczenia", + "differences": "{{count}} różnic", + "disabled": "Wyposażenie jest wyłączone w Twojej organizacji.", + "empty": "Nic tu nie ma.", + "errors": { + "CountAlreadyExists": "To liczenie już istnieje.", + "CountEmpty": "Nie ma tu nic do policzenia.", + "CountScopeTooLarge": "Zbyt wiele pozycji na jedno liczenie. Liczy po jednej skrytce.", + "CountSnapshotChanged": "Wyposażenie pojazdu zmieniło się podczas liczenia. Anuluj i zacznij od nowa.", + "CountStateConflict": "To liczenie zmieniło się na innym urządzeniu. Otwórz je ponownie.", + "InvalidCount": "Sprawdź policzone wartości i spróbuj ponownie.", + "InventoryDisabled": "Wyposażenie jest wyłączone w Twojej organizacji.", + "LocationUnavailable": "To miejsce jest niedostępne.", + "MembershipRequired": "Nie jesteś aktywnym członkiem tej organizacji.", + "MigrationRequired": "Wyposażenie jest konfigurowane dla Twojej organizacji.", + "PermissionRequired": "Nie masz uprawnień do tej czynności.", + "ProtectedDataRequired": "Odblokuj chronione dane, aby kontynuować.", + "ReturnAssetFirst": "Pozycja oznaczona jako brakująca jest nadal komuś wydana. Najpierw przyjmij zwrot w przeglądarce.", + "locked": "Odblokuj chronione dane, aby kontynuować.", + "retry": "Nie udało się połączyć z serwerem. Spróbuj ponownie." + }, + "expected": "Oczekiwano {{quantity}} {{unit}}", + "keep": "Liczę dalej", + "less": "O jeden mniej", + "matches": "Zgadza się", + "missing": "Brak", + "more": "O jeden więcej", + "noLocations": "Do tego pojazdu nie przypisano wyposażenia.", + "notMigrated": "Wyposażenie jest konfigurowane dla Twojej organizacji.", + "pickLocation": "Gdzie liczyć", + "present": "Jest", + "progress": "Policzono {{counted}} z {{total}}", + "quantity": "× {{quantity}}", + "save": "Zapisz postęp", + "selectUnit": "Najpierw wybierz swój pojazd.", + "serial": "Nr seryjny {{serial}}", + "start": "Rozpocznij liczenie", + "startCount": "Policz wyposażenie", + "startHelp": "Policz to, co faktycznie jest w pojeździe. Różnice zostaną zaksięgowane po zakończeniu.", + "title": "Wyposażenie", + "titleFor": "Wyposażenie · {{name}}", + "unnamedItem": "Pozycja", + "unnamedLocation": "Skrytka", + "wholeUnit": "Cały pojazd: {{name}}" + }, "livekit": { "audio_devices": "Urządzenia audio", "audio_settings": "Ustawienia audio", @@ -1064,14 +1139,17 @@ "title": "Notatki" }, "notifications": { + "additional_info": "Dodatkowe informacje", "confirm_delete_message_count": "Czy na pewno chcesz usunąć powiadomienia w liczbie {{count}}? Tej operacji nie można cofnąć.", "confirm_delete_message_one": "Czy na pewno chcesz usunąć to powiadomienie? Tej operacji nie można cofnąć.", "confirm_delete_title": "Potwierdź usunięcie", "delete_selected": "Usuń zaznaczone powiadomienia", "deselect_all": "Odznacz wszystko", + "detail_title": "Powiadomienie", "empty": "Brak nowych informacji", "enter_selection_mode": "Zaznacz powiadomienia", "open": "Otwórz powiadomienia", + "open_reference": "Otwórz", "remove_failed_count": "Nie udało się usunąć powiadomień", "remove_failed_one": "Nie udało się usunąć powiadomienia", "removed_count": "Usunięto powiadomienia: {{count}}", @@ -1080,11 +1158,8 @@ "selected_count": "Zaznaczono: {{count}}", "title": "Powiadomienia", "unable_to_load": "Nie można załadować powiadomień", - "detail_title": "Powiadomienie", - "additional_info": "Dodatkowe informacje", "view_call": "Wyświetl zgłoszenie", - "view_chat": "Zobacz czat", - "open_reference": "Otwórz" + "view_chat": "Zobacz czat" }, "onboarding": { "message": "Witamy w aplikacji Resgrid Unit" @@ -1092,6 +1167,7 @@ "operations": { "all": "Wszystkie", "back": "Wstecz", + "crewOf": "Załoga {{names}}", "disabled": "Rozmieszczenia nie są włączone dla tego wydziału.", "entryType": { "0": "Rozmieszczenie", @@ -1100,18 +1176,49 @@ }, "errors": { "denied": "Nie masz dostępu do tego rozmieszczenia.", + "deployments_attachment_too_large": "Paragon jest za duży.", + "deployments_closed": "To rozmieszczenie jest zamknięte.", "disabled": "Ta funkcja jest wyłączona dla Twojego wydziału.", + "expenses_receipt_invalid": "Nie udało się odczytać paragonu.", "locked": "Odblokuj dane chronione, aby kontynuować.", "retry": "Brak połączenia z serwerem. Spróbuj ponownie.", - "validation": "Serwer odrzucił zmianę.", - "timereports_subject_covered": "Ta załoga lub osoba ma już czas w innym raporcie z tego dnia.", "timereports_date_exists": "Raport dla tej załogi lub osoby już istnieje w tym dniu.", - "timereports_scope_invalid": "Ta załoga lub osoba nie jest aktywna w tym rozmieszczeniu.", "timereports_locked": "Tego raportu nie można już zmienić.", + "timereports_scope_invalid": "Ta załoga lub osoba nie jest aktywna w tym rozmieszczeniu.", + "timereports_subject_covered": "Ta załoga lub osoba ma już czas w innym raporcie z tego dnia.", "timereports_time_invalid": "Wpisz godziny w formacie HH:mm.", - "deployments_closed": "To rozmieszczenie jest zamknięte.", - "expenses_receipt_invalid": "Nie udało się odczytać paragonu.", - "deployments_attachment_too_large": "Paragon jest za duży." + "validation": "Serwer odrzucił zmianę." + }, + "expenses": { + "add": "Nowy wydatek", + "amount": "Kwota ({{currency}})", + "amountInvalid": "Wpisz kwotę, np. 42.50.", + "cancel": "Anuluj", + "choosePhoto": "Wybierz zdjęcie", + "city": "Miasto", + "description": "Opis", + "hasReceipt": "paragon", + "noneToday": "Brak wydatków w tym dniu.", + "otherDays": "Inne dni", + "photoDenied": "Zezwól na dostęp do aparatu lub zdjęć, aby dołączyć paragon.", + "receiptAttached": "Dołączono zdjęcie paragonu.", + "remove": "Usuń", + "removeBody": "Spowoduje to usunięcie wydatku i jego paragonu z rozmieszczenia.", + "removeTitle": "Usunąć wydatek?", + "save": "Dodaj wydatek", + "saved": "Wydatek zapisany.", + "takePhoto": "Zrób zdjęcie", + "title": "Wydatki", + "type": { + "0": "Dieta posiłkowa", + "1": "Zakwaterowanie", + "2": "Zakwaterowanie prywatne", + "3": "Prom", + "4": "Paliwo", + "5": "Uzupełnienie zapasów", + "6": "Inne" + }, + "typeLabel": "Rodzaj wydatku" }, "financeMode": { "0": "Operacyjne", @@ -1119,18 +1226,18 @@ "2": "Rozliczane" }, "issues": { + "break_rule": "Długa zmiana nie ma zapisanej przerwy.", "end_before_start": "Godzina zakończenia musi być późniejsza niż rozpoczęcia.", + "entries_overlap": "Dwa wpisy tej samej osoby lub jednostki nakładają się.", + "long_travel": "Więcej podróży niż dozwolone w ciągu dnia; wymaga zatwierdzenia.", "missing_subject": "Wybierz, kogo lub czego dotyczy ten wpis.", "missing_time": "Podaj godzinę rozpoczęcia i zakończenia.", + "no_entries": "Dodaj co najmniej jeden wpis.", + "outside_report_date": "Ten wpis wykracza poza datę raportu.", "overlap": "Ten wpis nakłada się na inny dla tej samej osoby lub jednostki.", - "subject_outside_report_scope": "Ten wpis dotyczy kogoś spoza załogi lub osoby tego raportu.", - "subject_on_other_report": "Ta osoba lub jednostka ma już czas w innym raporcie z tego samego dnia.", "subject_not_on_roster": "Ten wpis dotyczy kogoś, kogo nie ma w składzie.", - "entries_overlap": "Dwa wpisy tej samej osoby lub jednostki nakładają się.", - "no_entries": "Dodaj co najmniej jeden wpis.", - "break_rule": "Długa zmiana nie ma zapisanej przerwy.", - "long_travel": "Więcej podróży niż dozwolone w ciągu dnia; wymaga zatwierdzenia.", - "outside_report_date": "Ten wpis wykracza poza datę raportu." + "subject_on_other_report": "Ta osoba lub jednostka ma już czas w innym raporcie z tego samego dnia.", + "subject_outside_report_scope": "Ten wpis dotyczy kogoś spoza załogi lub osoby tego raportu." }, "mars": { "counts": "{{errors}} błędów · {{warnings}} ostrzeżeń", @@ -1144,6 +1251,7 @@ "title": "F-42", "validate": "Sprawdź" }, + "nextDay": "Następny dzień", "noNumbers": "Brak numerów zdarzenia lub zgłoszenia", "none": "Brak rozmieszczeń.", "open": "Otwarte", @@ -1153,6 +1261,7 @@ "2": "Zdarzenie", "3": "Powrót" }, + "previousDay": "Poprzedni dzień", "reportStatus": { "0": "Szkic", "1": "Przesłany", @@ -1161,6 +1270,22 @@ "4": "Anulowany" }, "roster": "{{units}} jednostek · {{personnel}} osób", + "scope": { + "crew": "Załoga: {{name}}", + "deployment": "Całe rozmieszczenie", + "individual": "{{name}}", + "mine": "Mój czas" + }, + "sign": { + "asCrewBoss": "Podpisz jako dowódca załogi", + "crewBossPending": "Jeszcze nie podpisane przez dowódcę załogi.", + "crewBossSigned": "Podpisane przez dowódcę załogi {{when}}", + "customerName": "Imię i nazwisko podpisującego z instytucji", + "customerPending": "Brak podpisu instytucji.", + "customerSigned": "Podpisujący z ramienia instytucji: {{name}}", + "recordCustomer": "Zapisz podpis instytucji", + "title": "Podpisy" + }, "status": { "0": "Zaplanowane", "1": "Gotowość", @@ -1172,35 +1297,35 @@ "time": { "accommodation": "Zakwaterowanie zapewnione przez agencję", "addEntry": "Dodaj wpis", + "applyToCrew": "Zastosuj do załogi", + "approve": "Zatwierdź raport", + "awaitingApproval": "Oczekuje na zatwierdzenie ({{count}})", + "coveredBy": "Ten czas jest już w raporcie #{{number}} z tego dnia.", + "crewReport": "Raport czasu załogi #{{number}} · {{name}}", + "dayReports": "Raporty z tego dnia", "from": "Od", "hours": "{{hours}} h", + "individualReport": "Raport czasu #{{number}} · {{name}}", "meals": "Posiłki zapewnione przez agencję", + "nextDay": "Kończy się następnego dnia", + "noCrewReport": "Brak raportu czasu załogi {{name}} w tym dniu.", "noEntries": "Brak wpisów.", "noReport": "Brak raportu czasu na ten dzień.", + "notOnRoster": "Nie jesteś w składzie ani w załodze tego rozmieszczenia.", "notes": "Notatki", + "readOnly": "Możesz przeglądać ten raport, ale nie możesz go zmienić.", "remove": "Usuń", "report": "Raport #{{number}}", "save": "Zapisz wpisy", "start": "Rozpocznij raport czasu", + "startCrew": "Rozpocznij raport czasu załogi", "subject": "Kto lub co", "submit": "Prześlij raport", "title": "Czas", "to": "Do", "total": "Razem {{hours}} h", "type": "Rodzaj wpisu", - "unpaidBreak": "Przerwa bezpłatna (min)", - "crewReport": "Raport czasu załogi #{{number}} · {{name}}", - "individualReport": "Raport czasu #{{number}} · {{name}}", - "noCrewReport": "Brak raportu czasu załogi {{name}} w tym dniu.", - "coveredBy": "Ten czas jest już w raporcie #{{number}} z tego dnia.", - "startCrew": "Rozpocznij raport czasu załogi", - "readOnly": "Możesz przeglądać ten raport, ale nie możesz go zmienić.", - "nextDay": "Kończy się następnego dnia", - "applyToCrew": "Zastosuj do załogi", - "approve": "Zatwierdź raport", - "dayReports": "Raporty z tego dnia", - "awaitingApproval": "Oczekuje na zatwierdzenie ({{count}})", - "notOnRoster": "Nie jesteś w składzie ani w załodze tego rozmieszczenia." + "unpaidBreak": "Przerwa bezpłatna (min)" }, "title": "Rozmieszczenia", "usage": { @@ -1214,56 +1339,6 @@ "startOdometer": "Licznik początkowy", "title": "Użycie", "unit": "Jednostka" - }, - "crewOf": "Załoga {{names}}", - "previousDay": "Poprzedni dzień", - "nextDay": "Następny dzień", - "scope": { - "deployment": "Całe rozmieszczenie", - "mine": "Mój czas", - "crew": "Załoga: {{name}}", - "individual": "{{name}}" - }, - "sign": { - "title": "Podpisy", - "crewBossSigned": "Podpisane przez dowódcę załogi {{when}}", - "crewBossPending": "Jeszcze nie podpisane przez dowódcę załogi.", - "customerSigned": "Podpisujący z ramienia instytucji: {{name}}", - "customerPending": "Brak podpisu instytucji.", - "asCrewBoss": "Podpisz jako dowódca załogi", - "customerName": "Imię i nazwisko podpisującego z instytucji", - "recordCustomer": "Zapisz podpis instytucji" - }, - "expenses": { - "title": "Wydatki", - "type": { - "0": "Dieta posiłkowa", - "1": "Zakwaterowanie", - "2": "Zakwaterowanie prywatne", - "3": "Prom", - "4": "Paliwo", - "5": "Uzupełnienie zapasów", - "6": "Inne" - }, - "typeLabel": "Rodzaj wydatku", - "amount": "Kwota ({{currency}})", - "description": "Opis", - "city": "Miasto", - "takePhoto": "Zrób zdjęcie", - "choosePhoto": "Wybierz zdjęcie", - "receiptAttached": "Dołączono zdjęcie paragonu.", - "save": "Dodaj wydatek", - "add": "Nowy wydatek", - "noneToday": "Brak wydatków w tym dniu.", - "otherDays": "Inne dni", - "hasReceipt": "paragon", - "remove": "Usuń", - "removeTitle": "Usunąć wydatek?", - "removeBody": "Spowoduje to usunięcie wydatku i jego paragonu z rozmieszczenia.", - "cancel": "Anuluj", - "saved": "Wydatek zapisany.", - "amountInvalid": "Wpisz kwotę, np. 42.50.", - "photoDenied": "Zezwól na dostęp do aparatu lub zdjęć, aby dołączyć paragon." } }, "protocols": { @@ -1662,6 +1737,7 @@ "background_location": "Lokalizacja w tle", "contact_us": "Skontaktuj się z nami", "current_unit": "Bieżąca jednostka", + "custom": "Niestandardowy", "english": "Angielski", "enter_password": "Wpisz hasło", "enter_server_url": "Wpisz URL API Resgrid (np. https://api.resgrid.com)", @@ -1772,6 +1848,7 @@ "station_destination_enabled": "Może reagować na stacje", "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Stacje", + "status_queued": "Status zapisany na tym urządzeniu. Zostanie wysłany automatycznie.", "status_saved_successfully": "Status zapisany pomyślnie!" }, "tabs": { @@ -1903,80 +1980,5 @@ "unknown": "Nieznany" } }, - "welcome": "Witamy w aplikacji Resgrid Unit", - "inventory": { - "title": "Wyposażenie", - "titleFor": "Wyposażenie · {{name}}", - "selectUnit": "Najpierw wybierz swój pojazd.", - "unnamedLocation": "Skrytka", - "unnamedItem": "Pozycja", - "countName": "Kontrola {{name}} {{date}}", - "notMigrated": "Wyposażenie jest konfigurowane dla Twojej organizacji.", - "disabled": "Wyposażenie jest wyłączone w Twojej organizacji.", - "noLocations": "Do tego pojazdu nie przypisano wyposażenia.", - "startCount": "Policz wyposażenie", - "wholeUnit": "Cały pojazd: {{name}}", - "pickLocation": "Gdzie liczyć", - "startHelp": "Policz to, co faktycznie jest w pojeździe. Różnice zostaną zaksięgowane po zakończeniu.", - "start": "Rozpocznij liczenie", - "cannotCount": "Liczenie wymaga uprawnienia do korekty wyposażenia w tym pojeździe.", - "counts": "Ostatnie liczenia", - "count": "Liczenie wyposażenia", - "empty": "Nic tu nie ma.", - "serial": "Nr seryjny {{serial}}", - "quantity": "× {{quantity}}", - "back": "Wstecz", - "completeTitle": "Zakończyć liczenie?", - "completeBody": "{{count}} pozycji różni się od oczekiwanych. Zakończenie zaksięguje różnice.", - "completeNoChanges": "Wszystko zgadza się z oczekiwanym.", - "cancel": "Anuluj", - "complete": "Zakończ", - "cancelTitle": "Anulować to liczenie?", - "cancelBody": "Nic z policzonego nie zostanie zaksięgowane.", - "keep": "Liczę dalej", - "cancelCount": "Anuluj liczenie", - "progress": "Policzono {{counted}} z {{total}}", - "differences": "{{count}} różnic", - "awaitingWitness": "Zmieniła się pozycja kontrolowana; druga osoba musi poświadczyć to liczenie w przeglądarce.", - "completed": "Liczenie zakończone.", - "expected": "Oczekiwano {{quantity}} {{unit}}", - "present": "Jest", - "missing": "Brak", - "less": "O jeden mniej", - "more": "O jeden więcej", - "matches": "Zgadza się", - "save": "Zapisz postęp", - "countStatus": { - "0": "W toku", - "1": "Oczekuje na świadka", - "2": "Zakończone", - "3": "Anulowane" - }, - "assetStatus": { - "0": "W użyciu", - "1": "Wydane", - "2": "W naprawie", - "3": "Uszkodzone", - "4": "Zgubione", - "5": "Zużyte", - "6": "Wycofane" - }, - "errors": { - "retry": "Nie udało się połączyć z serwerem. Spróbuj ponownie.", - "locked": "Odblokuj chronione dane, aby kontynuować.", - "PermissionRequired": "Nie masz uprawnień do tej czynności.", - "MembershipRequired": "Nie jesteś aktywnym członkiem tej organizacji.", - "InventoryDisabled": "Wyposażenie jest wyłączone w Twojej organizacji.", - "MigrationRequired": "Wyposażenie jest konfigurowane dla Twojej organizacji.", - "CountScopeTooLarge": "Zbyt wiele pozycji na jedno liczenie. Liczy po jednej skrytce.", - "CountEmpty": "Nie ma tu nic do policzenia.", - "CountSnapshotChanged": "Wyposażenie pojazdu zmieniło się podczas liczenia. Anuluj i zacznij od nowa.", - "CountStateConflict": "To liczenie zmieniło się na innym urządzeniu. Otwórz je ponownie.", - "ReturnAssetFirst": "Pozycja oznaczona jako brakująca jest nadal komuś wydana. Najpierw przyjmij zwrot w przeglądarce.", - "ProtectedDataRequired": "Odblokuj chronione dane, aby kontynuować.", - "CountAlreadyExists": "To liczenie już istnieje.", - "LocationUnavailable": "To miejsce jest niedostępne.", - "InvalidCount": "Sprawdź policzone wartości i spróbuj ponownie." - } - } + "welcome": "Witamy w aplikacji Resgrid Unit" } diff --git a/src/translations/sv.json b/src/translations/sv.json index 4c6c60a1..af8b8cb0 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -716,6 +716,7 @@ "address": "Adress", "bluesky": "Bluesky", "cancel": "Avbryt", + "categoryLabel": "Kategori: {{name}}", "cellPhone": "Mobiltelefon", "city": "Stad", "cityState": "Stad och stat", @@ -729,6 +730,7 @@ "contactNotesLoading": "Laddar kontaktanteckningar...", "contactType": "Kontakttyp", "countryId": "Lands-ID", + "customFields": "Anpassade fält", "delete": "Ta bort", "deleteConfirm": "Är du säker på att du vill ta bort denna kontakt?", "deleteSuccess": "Kontakten togs bort framgångsrikt", @@ -753,6 +755,7 @@ "share_unavailable": "Delning är inte tillgänglig på den här enheten." }, "formError": "Åtgärda felen i formuläret", + "hasGeofence": "Platsen har ett inritat geostaket.", "homePhone": "Hemtelefon", "identification": "Identifiering", "important": "Markera som viktig", @@ -762,6 +765,7 @@ "linkedin": "LinkedIn", "locationCoordinates": "Platskoordinater", "locationInformation": "Platsinformation", + "mailingAddress": "Postadress", "mastodon": "Mastodon", "mobile": "Mobil", "name": "Namn", @@ -773,6 +777,7 @@ "otherInfo": "Övrig information", "person": "Person", "phone": "Telefon", + "physicalAddress": "Besöksadress", "preplan": { "access": "Tillträde", "access_notes": "Anteckningar om tillträde", @@ -838,13 +843,8 @@ "unknown_person": "Okänd person", "visibility": "Synlighet", "website": "Webbplats", - "zip": "Postnummer", "withheld": "{{count}} skyddade uppgifter är dolda.", - "categoryLabel": "Kategori: {{name}}", - "physicalAddress": "Besöksadress", - "mailingAddress": "Postadress", - "hasGeofence": "Platsen har ett inritat geostaket.", - "customFields": "Anpassade fält" + "zip": "Postnummer" }, "data_protection": { "conceal": "Dölj igen", @@ -935,6 +935,81 @@ "send_message": "Message", "tab_title": "Ledning" }, + "inventory": { + "assetStatus": { + "0": "I tjänst", + "1": "Utlämnad", + "2": "På reparation", + "3": "Skadad", + "4": "Förlorad", + "5": "Förbrukad", + "6": "Avvecklad" + }, + "awaitingWitness": "En kontrollerad artikel ändrades; en andra person måste bevittna inventeringen på webben.", + "back": "Tillbaka", + "cancel": "Avbryt", + "cancelBody": "Inget av det som räknats bokförs.", + "cancelCount": "Avbryt inventering", + "cancelTitle": "Avbryta inventeringen?", + "cannotCount": "Inventering kräver behörigheten Justera förråd för den här enheten.", + "complete": "Avsluta", + "completeBody": "{{count}} artiklar avviker från det förväntade. Avslutning bokför avvikelserna.", + "completeNoChanges": "Allt stämmer med det förväntade.", + "completeTitle": "Avsluta inventeringen?", + "completed": "Inventeringen är klar.", + "count": "Inventering", + "countName": "Kontroll {{name}} {{date}}", + "countStatus": { + "0": "Pågår", + "1": "Väntar på vittne", + "2": "Klar", + "3": "Avbruten" + }, + "counts": "Senaste inventeringar", + "differences": "{{count}} avvikelser", + "disabled": "Förrådet är avstängt för er organisation.", + "empty": "Här finns inget.", + "errors": { + "CountAlreadyExists": "Inventeringen finns redan.", + "CountEmpty": "Det finns inget att räkna här.", + "CountScopeTooLarge": "För många artiklar för en inventering. Räkna ett fack i taget.", + "CountSnapshotChanged": "Enhetens förråd ändrades medan du räknade. Avbryt och börja om.", + "CountStateConflict": "Inventeringen ändrades på en annan enhet. Öppna den igen.", + "InvalidCount": "Kontrollera de räknade värdena och försök igen.", + "InventoryDisabled": "Förrådet är avstängt för er organisation.", + "LocationUnavailable": "Platsen är inte tillgänglig.", + "MembershipRequired": "Du är inte aktiv medlem i organisationen.", + "MigrationRequired": "Förrådet håller på att sättas upp för er organisation.", + "PermissionRequired": "Du har inte behörighet att göra detta.", + "ProtectedDataRequired": "Lås upp skyddade uppgifter för att fortsätta.", + "ReturnAssetFirst": "En artikel som markerats saknad är fortfarande utlämnad. Återlämna den först på webben.", + "locked": "Lås upp skyddade uppgifter för att fortsätta.", + "retry": "Kunde inte nå servern. Försök igen." + }, + "expected": "Förväntat {{quantity}} {{unit}}", + "keep": "Fortsätt räkna", + "less": "En mindre", + "matches": "Stämmer", + "missing": "Saknas", + "more": "En till", + "noLocations": "Ingen utrustning är kopplad till den här enheten.", + "notMigrated": "Förrådet håller på att sättas upp för er organisation.", + "pickLocation": "Var inventeringen görs", + "present": "Finns", + "progress": "{{counted}} av {{total}} räknade", + "quantity": "× {{quantity}}", + "save": "Spara läget", + "selectUnit": "Välj din enhet först.", + "serial": "Serienr {{serial}}", + "start": "Starta inventering", + "startCount": "Inventera", + "startHelp": "Räkna det som faktiskt finns på enheten. Avvikelser bokförs när inventeringen avslutas.", + "title": "Förråd", + "titleFor": "Förråd · {{name}}", + "unnamedItem": "Artikel", + "unnamedLocation": "Fack", + "wholeUnit": "Hela enheten: {{name}}" + }, "livekit": { "audio_devices": "Ljudenheter", "audio_settings": "Ljudinställningar", @@ -1064,14 +1139,17 @@ "title": "Anteckningar" }, "notifications": { + "additional_info": "Ytterligare information", "confirm_delete_message_count": "Är du säker på att du vill ta bort {{count}} aviseringar? Åtgärden kan inte ångras.", "confirm_delete_message_one": "Är du säker på att du vill ta bort den här aviseringen? Åtgärden kan inte ångras.", "confirm_delete_title": "Bekräfta borttagning", "delete_selected": "Ta bort markerade aviseringar", "deselect_all": "Avmarkera alla", + "detail_title": "Avisering", "empty": "Inga uppdateringar tillgängliga", "enter_selection_mode": "Markera aviseringar", "open": "Öppna aviseringar", + "open_reference": "Öppna", "remove_failed_count": "Det gick inte att ta bort aviseringarna", "remove_failed_one": "Det gick inte att ta bort aviseringen", "removed_count": "{{count}} aviseringar har tagits bort", @@ -1080,11 +1158,8 @@ "selected_count": "{{count}} markerade", "title": "Aviseringar", "unable_to_load": "Det gick inte att läsa in aviseringarna", - "detail_title": "Avisering", - "additional_info": "Ytterligare information", "view_call": "Visa samtal", - "view_chat": "Visa chatt", - "open_reference": "Öppna" + "view_chat": "Visa chatt" }, "onboarding": { "message": "Välkommen till Resgrid Unit app site" @@ -1092,6 +1167,7 @@ "operations": { "all": "Alla", "back": "Tillbaka", + "crewOf": "Besättning på {{names}}", "disabled": "Insatser är inte aktiverade för den här avdelningen.", "entryType": { "0": "Insats", @@ -1100,18 +1176,49 @@ }, "errors": { "denied": "Du har inte åtkomst till den här insatsen.", + "deployments_attachment_too_large": "Kvittot är för stort.", + "deployments_closed": "Insatsen är avslutad.", "disabled": "Funktionen är avstängd för din avdelning.", + "expenses_receipt_invalid": "Kvittot kunde inte läsas.", "locked": "Lås upp skyddade data för att fortsätta.", "retry": "Kunde inte nå servern. Försök igen.", - "validation": "Servern avvisade ändringen.", - "timereports_subject_covered": "Den besättningen eller personen har redan tid i en annan rapport denna dag.", "timereports_date_exists": "Det finns redan en rapport för den besättningen eller personen den dagen.", - "timereports_scope_invalid": "Den besättningen eller personen är inte aktiv i denna insats.", "timereports_locked": "Rapporten kan inte längre ändras.", + "timereports_scope_invalid": "Den besättningen eller personen är inte aktiv i denna insats.", + "timereports_subject_covered": "Den besättningen eller personen har redan tid i en annan rapport denna dag.", "timereports_time_invalid": "Ange tider som HH:mm.", - "deployments_closed": "Insatsen är avslutad.", - "expenses_receipt_invalid": "Kvittot kunde inte läsas.", - "deployments_attachment_too_large": "Kvittot är för stort." + "validation": "Servern avvisade ändringen." + }, + "expenses": { + "add": "Nytt utlägg", + "amount": "Belopp ({{currency}})", + "amountInvalid": "Ange ett belopp som 42.50.", + "cancel": "Avbryt", + "choosePhoto": "Välj foto", + "city": "Ort", + "description": "Beskrivning", + "hasReceipt": "kvitto", + "noneToday": "Inga utlägg denna dag.", + "otherDays": "Andra dagar", + "photoDenied": "Tillåt åtkomst till kamera eller bilder för att bifoga ett kvitto.", + "receiptAttached": "Kvittofoto bifogat.", + "remove": "Ta bort", + "removeBody": "Utlägget och dess kvitto tas bort från insatsen.", + "removeTitle": "Ta bort utlägget?", + "save": "Lägg till utlägg", + "saved": "Utlägget sparades.", + "takePhoto": "Ta foto", + "title": "Utlägg", + "type": { + "0": "Traktamente måltider", + "1": "Logi", + "2": "Privat logi", + "3": "Färja", + "4": "Bränsle", + "5": "Materialpåfyllning", + "6": "Övrigt" + }, + "typeLabel": "Typ av utlägg" }, "financeMode": { "0": "Operativ", @@ -1119,18 +1226,18 @@ "2": "Fakturerbar" }, "issues": { + "break_rule": "Ett långt pass saknar registrerad rast.", "end_before_start": "Sluttiden måste vara efter starttiden.", + "entries_overlap": "Två poster för samma person eller enhet överlappar.", + "long_travel": "Mer resa än tillåtet på en dag; kräver godkännande.", "missing_subject": "Välj vem eller vad posten gäller.", "missing_time": "Ange start- och sluttid.", + "no_entries": "Lägg till minst en post.", + "outside_report_date": "Posten ligger utanför rapportens datum.", "overlap": "Posten överlappar en annan för samma person eller enhet.", - "subject_outside_report_scope": "Posten gäller någon utanför rapportens besättning eller person.", - "subject_on_other_report": "Den här personen eller enheten har redan tid i en annan rapport samma dag.", "subject_not_on_roster": "Posten gäller någon som inte finns i bemanningen.", - "entries_overlap": "Två poster för samma person eller enhet överlappar.", - "no_entries": "Lägg till minst en post.", - "break_rule": "Ett långt pass saknar registrerad rast.", - "long_travel": "Mer resa än tillåtet på en dag; kräver godkännande.", - "outside_report_date": "Posten ligger utanför rapportens datum." + "subject_on_other_report": "Den här personen eller enheten har redan tid i en annan rapport samma dag.", + "subject_outside_report_scope": "Posten gäller någon utanför rapportens besättning eller person." }, "mars": { "counts": "{{errors}} fel · {{warnings}} varningar", @@ -1144,6 +1251,7 @@ "title": "F-42", "validate": "Validera" }, + "nextDay": "Nästa dag", "noNumbers": "Inga händelse- eller begärannummer", "none": "Inga insatser.", "open": "Öppna", @@ -1153,6 +1261,7 @@ "2": "Händelse", "3": "Återresa" }, + "previousDay": "Föregående dag", "reportStatus": { "0": "Utkast", "1": "Inskickad", @@ -1161,6 +1270,22 @@ "4": "Makulerad" }, "roster": "{{units}} enheter · {{personnel}} personer", + "scope": { + "crew": "Besättning: {{name}}", + "deployment": "Hela insatsen", + "individual": "{{name}}", + "mine": "Min tid" + }, + "sign": { + "asCrewBoss": "Signera som besättningschef", + "crewBossPending": "Ännu inte signerad av besättningschefen.", + "crewBossSigned": "Signerad av besättningschefen {{when}}", + "customerName": "Myndighetens undertecknares namn", + "customerPending": "Ingen underskrift från myndigheten registrerad.", + "customerSigned": "Myndighetens undertecknare: {{name}}", + "recordCustomer": "Registrera myndighetens underskrift", + "title": "Underskrifter" + }, "status": { "0": "Planerad", "1": "Beredskap", @@ -1172,35 +1297,35 @@ "time": { "accommodation": "Boende tillhandahålls av myndigheten", "addEntry": "Lägg till post", + "applyToCrew": "Använd för besättningen", + "approve": "Godkänn rapport", + "awaitingApproval": "Väntar på godkännande ({{count}})", + "coveredBy": "Den här tiden finns redan i rapport #{{number}} för denna dag.", + "crewReport": "Besättningens tidrapport #{{number}} · {{name}}", + "dayReports": "Rapporter denna dag", "from": "Från", "hours": "{{hours}} h", + "individualReport": "Tidrapport #{{number}} · {{name}}", "meals": "Måltider tillhandahålls av myndigheten", + "nextDay": "Slutar nästa dag", + "noCrewReport": "Ingen tidrapport för besättningen {{name}} denna dag.", "noEntries": "Inga poster ännu.", "noReport": "Ingen tidrapport för den här dagen.", + "notOnRoster": "Du finns inte i bemanningen eller någon besättning i denna insats.", "notes": "Anteckningar", + "readOnly": "Du kan se rapporten men inte ändra den.", "remove": "Ta bort", "report": "Rapport #{{number}}", "save": "Spara poster", "start": "Starta tidrapport", + "startCrew": "Starta besättningens tidrapport", "subject": "Vem eller vad", "submit": "Skicka in rapport", "title": "Tid", "to": "Till", "total": "Totalt {{hours}} h", "type": "Posttyp", - "unpaidBreak": "Obetald rast (min)", - "crewReport": "Besättningens tidrapport #{{number}} · {{name}}", - "individualReport": "Tidrapport #{{number}} · {{name}}", - "noCrewReport": "Ingen tidrapport för besättningen {{name}} denna dag.", - "coveredBy": "Den här tiden finns redan i rapport #{{number}} för denna dag.", - "startCrew": "Starta besättningens tidrapport", - "readOnly": "Du kan se rapporten men inte ändra den.", - "nextDay": "Slutar nästa dag", - "applyToCrew": "Använd för besättningen", - "approve": "Godkänn rapport", - "dayReports": "Rapporter denna dag", - "awaitingApproval": "Väntar på godkännande ({{count}})", - "notOnRoster": "Du finns inte i bemanningen eller någon besättning i denna insats." + "unpaidBreak": "Obetald rast (min)" }, "title": "Insatser", "usage": { @@ -1214,56 +1339,6 @@ "startOdometer": "Mätarställning start", "title": "Användning", "unit": "Enhet" - }, - "crewOf": "Besättning på {{names}}", - "previousDay": "Föregående dag", - "nextDay": "Nästa dag", - "scope": { - "deployment": "Hela insatsen", - "mine": "Min tid", - "crew": "Besättning: {{name}}", - "individual": "{{name}}" - }, - "sign": { - "title": "Underskrifter", - "crewBossSigned": "Signerad av besättningschefen {{when}}", - "crewBossPending": "Ännu inte signerad av besättningschefen.", - "customerSigned": "Myndighetens undertecknare: {{name}}", - "customerPending": "Ingen underskrift från myndigheten registrerad.", - "asCrewBoss": "Signera som besättningschef", - "customerName": "Myndighetens undertecknares namn", - "recordCustomer": "Registrera myndighetens underskrift" - }, - "expenses": { - "title": "Utlägg", - "type": { - "0": "Traktamente måltider", - "1": "Logi", - "2": "Privat logi", - "3": "Färja", - "4": "Bränsle", - "5": "Materialpåfyllning", - "6": "Övrigt" - }, - "typeLabel": "Typ av utlägg", - "amount": "Belopp ({{currency}})", - "description": "Beskrivning", - "city": "Ort", - "takePhoto": "Ta foto", - "choosePhoto": "Välj foto", - "receiptAttached": "Kvittofoto bifogat.", - "save": "Lägg till utlägg", - "add": "Nytt utlägg", - "noneToday": "Inga utlägg denna dag.", - "otherDays": "Andra dagar", - "hasReceipt": "kvitto", - "remove": "Ta bort", - "removeTitle": "Ta bort utlägget?", - "removeBody": "Utlägget och dess kvitto tas bort från insatsen.", - "cancel": "Avbryt", - "saved": "Utlägget sparades.", - "amountInvalid": "Ange ett belopp som 42.50.", - "photoDenied": "Tillåt åtkomst till kamera eller bilder för att bifoga ett kvitto." } }, "protocols": { @@ -1662,6 +1737,7 @@ "background_location": "Bakgrundsplats", "contact_us": "Kontakta oss", "current_unit": "Aktuell enhet", + "custom": "Anpassad", "english": "Engelska", "enter_password": "Ange ditt lösenord", "enter_server_url": "Ange Resgrid API URL (t.ex. https://api.resgrid.com)", @@ -1772,6 +1848,7 @@ "station_destination_enabled": "Kan svara på stationer", "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Stationer", + "status_queued": "Status sparad på den här enheten. Den skickas automatiskt.", "status_saved_successfully": "Status sparades framgångsrikt!" }, "tabs": { @@ -1903,80 +1980,5 @@ "unknown": "Okänd" } }, - "welcome": "Välkommen till Resgrid Unit app site", - "inventory": { - "title": "Förråd", - "titleFor": "Förråd · {{name}}", - "selectUnit": "Välj din enhet först.", - "unnamedLocation": "Fack", - "unnamedItem": "Artikel", - "countName": "Kontroll {{name}} {{date}}", - "notMigrated": "Förrådet håller på att sättas upp för er organisation.", - "disabled": "Förrådet är avstängt för er organisation.", - "noLocations": "Ingen utrustning är kopplad till den här enheten.", - "startCount": "Inventera", - "wholeUnit": "Hela enheten: {{name}}", - "pickLocation": "Var inventeringen görs", - "startHelp": "Räkna det som faktiskt finns på enheten. Avvikelser bokförs när inventeringen avslutas.", - "start": "Starta inventering", - "cannotCount": "Inventering kräver behörigheten Justera förråd för den här enheten.", - "counts": "Senaste inventeringar", - "count": "Inventering", - "empty": "Här finns inget.", - "serial": "Serienr {{serial}}", - "quantity": "× {{quantity}}", - "back": "Tillbaka", - "completeTitle": "Avsluta inventeringen?", - "completeBody": "{{count}} artiklar avviker från det förväntade. Avslutning bokför avvikelserna.", - "completeNoChanges": "Allt stämmer med det förväntade.", - "cancel": "Avbryt", - "complete": "Avsluta", - "cancelTitle": "Avbryta inventeringen?", - "cancelBody": "Inget av det som räknats bokförs.", - "keep": "Fortsätt räkna", - "cancelCount": "Avbryt inventering", - "progress": "{{counted}} av {{total}} räknade", - "differences": "{{count}} avvikelser", - "awaitingWitness": "En kontrollerad artikel ändrades; en andra person måste bevittna inventeringen på webben.", - "completed": "Inventeringen är klar.", - "expected": "Förväntat {{quantity}} {{unit}}", - "present": "Finns", - "missing": "Saknas", - "less": "En mindre", - "more": "En till", - "matches": "Stämmer", - "save": "Spara läget", - "countStatus": { - "0": "Pågår", - "1": "Väntar på vittne", - "2": "Klar", - "3": "Avbruten" - }, - "assetStatus": { - "0": "I tjänst", - "1": "Utlämnad", - "2": "På reparation", - "3": "Skadad", - "4": "Förlorad", - "5": "Förbrukad", - "6": "Avvecklad" - }, - "errors": { - "retry": "Kunde inte nå servern. Försök igen.", - "locked": "Lås upp skyddade uppgifter för att fortsätta.", - "PermissionRequired": "Du har inte behörighet att göra detta.", - "MembershipRequired": "Du är inte aktiv medlem i organisationen.", - "InventoryDisabled": "Förrådet är avstängt för er organisation.", - "MigrationRequired": "Förrådet håller på att sättas upp för er organisation.", - "CountScopeTooLarge": "För många artiklar för en inventering. Räkna ett fack i taget.", - "CountEmpty": "Det finns inget att räkna här.", - "CountSnapshotChanged": "Enhetens förråd ändrades medan du räknade. Avbryt och börja om.", - "CountStateConflict": "Inventeringen ändrades på en annan enhet. Öppna den igen.", - "ReturnAssetFirst": "En artikel som markerats saknad är fortfarande utlämnad. Återlämna den först på webben.", - "ProtectedDataRequired": "Lås upp skyddade uppgifter för att fortsätta.", - "CountAlreadyExists": "Inventeringen finns redan.", - "LocationUnavailable": "Platsen är inte tillgänglig.", - "InvalidCount": "Kontrollera de räknade värdena och försök igen." - } - } + "welcome": "Välkommen till Resgrid Unit app site" } diff --git a/src/translations/uk.json b/src/translations/uk.json index 4fdff220..cca3d295 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -716,6 +716,7 @@ "address": "Адреса", "bluesky": "Bluesky", "cancel": "Скасувати", + "categoryLabel": "Категорія: {{name}}", "cellPhone": "Мобільний телефон", "city": "Місто", "cityState": "Місто та область", @@ -729,6 +730,7 @@ "contactNotesLoading": "Завантаження приміток контакту...", "contactType": "Тип контакту", "countryId": "ID країни", + "customFields": "Власні поля", "delete": "Видалити", "deleteConfirm": "Ви впевнені, що хочете видалити цей контакт?", "deleteSuccess": "Контакт успішно видалено", @@ -753,6 +755,7 @@ "share_unavailable": "Поширення недоступне на цьому пристрої." }, "formError": "Виправте помилки у формі", + "hasGeofence": "Для цього місця на карті позначено геозону.", "homePhone": "Домашній телефон", "identification": "Ідентифікація", "important": "Позначити як важливе", @@ -762,6 +765,7 @@ "linkedin": "LinkedIn", "locationCoordinates": "Координати місцезнаходження", "locationInformation": "Інформація про місцезнаходження", + "mailingAddress": "Поштова адреса", "mastodon": "Mastodon", "mobile": "Мобільний", "name": "Ім'я", @@ -773,6 +777,7 @@ "otherInfo": "Інша інформація", "person": "Особа", "phone": "Телефон", + "physicalAddress": "Фактична адреса", "preplan": { "access": "Доступ", "access_notes": "Нотатки про доступ", @@ -838,13 +843,8 @@ "unknown_person": "Невідома особа", "visibility": "Видимість", "website": "Веб-сайт", - "zip": "Поштовий індекс", "withheld": "Приховано {{count}} захищених даних.", - "categoryLabel": "Категорія: {{name}}", - "physicalAddress": "Фактична адреса", - "mailingAddress": "Поштова адреса", - "hasGeofence": "Для цього місця на карті позначено геозону.", - "customFields": "Власні поля" + "zip": "Поштовий індекс" }, "data_protection": { "conceal": "Приховати знову", @@ -935,6 +935,81 @@ "send_message": "Message", "tab_title": "Керування" }, + "inventory": { + "assetStatus": { + "0": "У службі", + "1": "Видано", + "2": "У ремонті", + "3": "Пошкоджено", + "4": "Втрачено", + "5": "Використано", + "6": "Списано" + }, + "awaitingWitness": "Змінилася підконтрольна позиція; друга особа має засвідчити перерахунок у вебі.", + "back": "Назад", + "cancel": "Скасувати", + "cancelBody": "Нічого з пораховано не буде проведено.", + "cancelCount": "Скасувати перерахунок", + "cancelTitle": "Скасувати цей перерахунок?", + "cannotCount": "Для перерахунку потрібен дозвіл на коригування майна цього підрозділу.", + "complete": "Завершити", + "completeBody": "{{count}} позицій відрізняються від очікуваного. Завершення проведе розбіжності.", + "completeNoChanges": "Усе відповідає очікуваному.", + "completeTitle": "Завершити перерахунок?", + "completed": "Перерахунок завершено.", + "count": "Перерахунок майна", + "countName": "Перевірка {{name}} {{date}}", + "countStatus": { + "0": "Триває", + "1": "Очікує свідка", + "2": "Завершено", + "3": "Скасовано" + }, + "counts": "Останні перерахунки", + "differences": "{{count}} розбіжностей", + "disabled": "Облік майна вимкнено для вашої організації.", + "empty": "Тут нічого немає.", + "errors": { + "CountAlreadyExists": "Такий перерахунок уже існує.", + "CountEmpty": "Тут нічого рахувати.", + "CountScopeTooLarge": "Забагато позицій для одного перерахунку. Рахуйте по одному відсіку.", + "CountSnapshotChanged": "Майно підрозділу змінилося під час перерахунку. Скасуйте й почніть знову.", + "CountStateConflict": "Цей перерахунок змінено на іншому пристрої. Відкрийте його знову.", + "InvalidCount": "Перевірте пораховані значення та спробуйте ще раз.", + "InventoryDisabled": "Облік майна вимкнено для вашої організації.", + "LocationUnavailable": "Це місце недоступне.", + "MembershipRequired": "Ви не є активним членом цієї організації.", + "MigrationRequired": "Облік майна налаштовується для вашої організації.", + "PermissionRequired": "У вас немає дозволу на цю дію.", + "ProtectedDataRequired": "Розблокуйте захищені дані, щоб продовжити.", + "ReturnAssetFirst": "Позицію, позначену відсутньою, досі видано комусь. Спершу оформіть повернення у вебі.", + "locked": "Розблокуйте захищені дані, щоб продовжити.", + "retry": "Не вдалося зв'язатися із сервером. Спробуйте ще раз." + }, + "expected": "Очікується {{quantity}} {{unit}}", + "keep": "Рахувати далі", + "less": "На одну менше", + "matches": "Збігається", + "missing": "Відсутнє", + "more": "На одну більше", + "noLocations": "За цим підрозділом не закріплено майна.", + "notMigrated": "Облік майна налаштовується для вашої організації.", + "pickLocation": "Де рахувати", + "present": "Є", + "progress": "Пораховано {{counted}} з {{total}}", + "quantity": "× {{quantity}}", + "save": "Зберегти прогрес", + "selectUnit": "Спершу виберіть свій підрозділ.", + "serial": "Серійний № {{serial}}", + "start": "Почати перерахунок", + "startCount": "Перерахувати майно", + "startHelp": "Рахуйте те, що фактично є на техніці. Розбіжності буде проведено після завершення.", + "title": "Майно", + "titleFor": "Майно · {{name}}", + "unnamedItem": "Позиція", + "unnamedLocation": "Відсік", + "wholeUnit": "Увесь підрозділ: {{name}}" + }, "livekit": { "audio_devices": "Аудіопристрої", "audio_settings": "Налаштування звуку", @@ -1064,14 +1139,17 @@ "title": "Примітки" }, "notifications": { + "additional_info": "Додаткова інформація", "confirm_delete_message_count": "Ви впевнені, що хочете видалити сповіщення ({{count}})? Цю дію не можна скасувати.", "confirm_delete_message_one": "Ви впевнені, що хочете видалити це сповіщення? Цю дію не можна скасувати.", "confirm_delete_title": "Підтвердити видалення", "delete_selected": "Видалити вибрані сповіщення", "deselect_all": "Зняти вибір", + "detail_title": "Сповіщення", "empty": "Немає оновлень", "enter_selection_mode": "Вибрати сповіщення", "open": "Відкрити сповіщення", + "open_reference": "Відкрити", "remove_failed_count": "Не вдалося видалити сповіщення", "remove_failed_one": "Не вдалося видалити сповіщення", "removed_count": "Видалено сповіщень: {{count}}", @@ -1080,11 +1158,8 @@ "selected_count": "Вибрано: {{count}}", "title": "Сповіщення", "unable_to_load": "Не вдалося завантажити сповіщення", - "detail_title": "Сповіщення", - "additional_info": "Додаткова інформація", "view_call": "Переглянути виклик", - "view_chat": "Переглянути чат", - "open_reference": "Відкрити" + "view_chat": "Переглянути чат" }, "onboarding": { "message": "Ласкаво просимо до додатку Resgrid Unit" @@ -1092,6 +1167,7 @@ "operations": { "all": "Усі", "back": "Назад", + "crewOf": "Екіпаж {{names}}", "disabled": "Розгортання не ввімкнено для цього підрозділу.", "entryType": { "0": "Розгортання", @@ -1100,18 +1176,49 @@ }, "errors": { "denied": "У вас немає доступу до цього розгортання.", + "deployments_attachment_too_large": "Чек завеликий.", + "deployments_closed": "Це розгортання закрите.", "disabled": "Цю функцію вимкнено для вашого підрозділу.", + "expenses_receipt_invalid": "Не вдалося прочитати чек.", "locked": "Розблокуйте захищені дані, щоб продовжити.", "retry": "Не вдалося з’єднатися з сервером. Спробуйте ще раз.", - "validation": "Сервер відхилив зміну.", - "timereports_subject_covered": "Цей екіпаж або особа вже мають час в іншому звіті за цей день.", "timereports_date_exists": "Звіт для цього екіпажу або особи за той день уже існує.", - "timereports_scope_invalid": "Цей екіпаж або особа не активні в цьому розгортанні.", "timereports_locked": "Цей звіт більше не можна змінити.", + "timereports_scope_invalid": "Цей екіпаж або особа не активні в цьому розгортанні.", + "timereports_subject_covered": "Цей екіпаж або особа вже мають час в іншому звіті за цей день.", "timereports_time_invalid": "Вводьте час у форматі HH:mm.", - "deployments_closed": "Це розгортання закрите.", - "expenses_receipt_invalid": "Не вдалося прочитати чек.", - "deployments_attachment_too_large": "Чек завеликий." + "validation": "Сервер відхилив зміну." + }, + "expenses": { + "add": "Нова витрата", + "amount": "Сума ({{currency}})", + "amountInvalid": "Введіть суму, наприклад 42.50.", + "cancel": "Скасувати", + "choosePhoto": "Вибрати фото", + "city": "Місто", + "description": "Опис", + "hasReceipt": "чек", + "noneToday": "Немає витрат за цей день.", + "otherDays": "Інші дні", + "photoDenied": "Дозвольте доступ до камери або фото, щоб додати чек.", + "receiptAttached": "Фото чека додано.", + "remove": "Видалити", + "removeBody": "Це видалить витрату та її чек із розгортання.", + "removeTitle": "Видалити витрату?", + "save": "Додати витрату", + "saved": "Витрату збережено.", + "takePhoto": "Зробити фото", + "title": "Витрати", + "type": { + "0": "Добові на харчування", + "1": "Проживання", + "2": "Приватне проживання", + "3": "Пором", + "4": "Пальне", + "5": "Поповнення запасів", + "6": "Інше" + }, + "typeLabel": "Тип витрати" }, "financeMode": { "0": "Оперативне", @@ -1119,18 +1226,18 @@ "2": "Оплачуване" }, "issues": { + "break_rule": "Для довгої зміни не записано перерви.", "end_before_start": "Час завершення має бути пізніше за час початку.", + "entries_overlap": "Два записи тієї самої особи або підрозділу перекриваються.", + "long_travel": "Більше переїздів, ніж дозволено за день; потрібне затвердження.", "missing_subject": "Виберіть, кого або чого стосується цей запис.", "missing_time": "Введіть час початку та завершення.", + "no_entries": "Додайте принаймні один запис.", + "outside_report_date": "Цей запис виходить за межі дати звіту.", "overlap": "Цей запис перетинається з іншим для тієї самої особи або підрозділу.", - "subject_outside_report_scope": "Цей запис стосується когось поза екіпажем або особою цього звіту.", - "subject_on_other_report": "Ця особа або підрозділ уже має час в іншому звіті за той самий день.", "subject_not_on_roster": "Цей запис стосується когось, кого немає у складі.", - "entries_overlap": "Два записи тієї самої особи або підрозділу перекриваються.", - "no_entries": "Додайте принаймні один запис.", - "break_rule": "Для довгої зміни не записано перерви.", - "long_travel": "Більше переїздів, ніж дозволено за день; потрібне затвердження.", - "outside_report_date": "Цей запис виходить за межі дати звіту." + "subject_on_other_report": "Ця особа або підрозділ уже має час в іншому звіті за той самий день.", + "subject_outside_report_scope": "Цей запис стосується когось поза екіпажем або особою цього звіту." }, "mars": { "counts": "{{errors}} помилок · {{warnings}} попереджень", @@ -1144,6 +1251,7 @@ "title": "F-42", "validate": "Перевірити" }, + "nextDay": "Наступний день", "noNumbers": "Немає номерів інциденту чи запиту", "none": "Розгортань немає.", "open": "Відкриті", @@ -1153,6 +1261,7 @@ "2": "Інцидент", "3": "Повернення" }, + "previousDay": "Попередній день", "reportStatus": { "0": "Чернетка", "1": "Подано", @@ -1161,6 +1270,22 @@ "4": "Скасовано" }, "roster": "{{units}} підрозділів · {{personnel}} осіб", + "scope": { + "crew": "Екіпаж: {{name}}", + "deployment": "Усе розгортання", + "individual": "{{name}}", + "mine": "Мій час" + }, + "sign": { + "asCrewBoss": "Підписати як командир екіпажу", + "crewBossPending": "Командир екіпажу ще не підписав.", + "crewBossSigned": "Підписано командиром екіпажу {{when}}", + "customerName": "Ім'я підписанта установи", + "customerPending": "Підпис установи не записано.", + "customerSigned": "Підписант установи: {{name}}", + "recordCustomer": "Записати підпис установи", + "title": "Підписи" + }, "status": { "0": "Заплановано", "1": "Очікування", @@ -1172,35 +1297,35 @@ "time": { "accommodation": "Проживання надає агентство", "addEntry": "Додати запис", + "applyToCrew": "Застосувати до екіпажу", + "approve": "Затвердити звіт", + "awaitingApproval": "Очікують затвердження ({{count}})", + "coveredBy": "Цей час уже є у звіті #{{number}} за цей день.", + "crewReport": "Звіт про час екіпажу #{{number}} · {{name}}", + "dayReports": "Звіти за цей день", "from": "З", "hours": "{{hours}} год", + "individualReport": "Звіт про час #{{number}} · {{name}}", "meals": "Харчування надає агентство", + "nextDay": "Закінчується наступного дня", + "noCrewReport": "Немає звіту про час екіпажу {{name}} за цей день.", "noEntries": "Записів ще немає.", "noReport": "Немає звіту про час за цей день.", + "notOnRoster": "Ви не входите до складу чи екіпажу цього розгортання.", "notes": "Нотатки", + "readOnly": "Ви можете переглядати цей звіт, але не змінювати його.", "remove": "Видалити", "report": "Звіт #{{number}}", "save": "Зберегти записи", "start": "Почати звіт про час", + "startCrew": "Почати звіт про час екіпажу", "subject": "Хто або що", "submit": "Подати звіт", "title": "Час", "to": "До", "total": "Разом {{hours}} год", "type": "Тип запису", - "unpaidBreak": "Неоплачувана перерва (хв)", - "crewReport": "Звіт про час екіпажу #{{number}} · {{name}}", - "individualReport": "Звіт про час #{{number}} · {{name}}", - "noCrewReport": "Немає звіту про час екіпажу {{name}} за цей день.", - "coveredBy": "Цей час уже є у звіті #{{number}} за цей день.", - "startCrew": "Почати звіт про час екіпажу", - "readOnly": "Ви можете переглядати цей звіт, але не змінювати його.", - "nextDay": "Закінчується наступного дня", - "applyToCrew": "Застосувати до екіпажу", - "approve": "Затвердити звіт", - "dayReports": "Звіти за цей день", - "awaitingApproval": "Очікують затвердження ({{count}})", - "notOnRoster": "Ви не входите до складу чи екіпажу цього розгортання." + "unpaidBreak": "Неоплачувана перерва (хв)" }, "title": "Розгортання", "usage": { @@ -1214,56 +1339,6 @@ "startOdometer": "Одометр на початку", "title": "Використання", "unit": "Підрозділ" - }, - "crewOf": "Екіпаж {{names}}", - "previousDay": "Попередній день", - "nextDay": "Наступний день", - "scope": { - "deployment": "Усе розгортання", - "mine": "Мій час", - "crew": "Екіпаж: {{name}}", - "individual": "{{name}}" - }, - "sign": { - "title": "Підписи", - "crewBossSigned": "Підписано командиром екіпажу {{when}}", - "crewBossPending": "Командир екіпажу ще не підписав.", - "customerSigned": "Підписант установи: {{name}}", - "customerPending": "Підпис установи не записано.", - "asCrewBoss": "Підписати як командир екіпажу", - "customerName": "Ім'я підписанта установи", - "recordCustomer": "Записати підпис установи" - }, - "expenses": { - "title": "Витрати", - "type": { - "0": "Добові на харчування", - "1": "Проживання", - "2": "Приватне проживання", - "3": "Пором", - "4": "Пальне", - "5": "Поповнення запасів", - "6": "Інше" - }, - "typeLabel": "Тип витрати", - "amount": "Сума ({{currency}})", - "description": "Опис", - "city": "Місто", - "takePhoto": "Зробити фото", - "choosePhoto": "Вибрати фото", - "receiptAttached": "Фото чека додано.", - "save": "Додати витрату", - "add": "Нова витрата", - "noneToday": "Немає витрат за цей день.", - "otherDays": "Інші дні", - "hasReceipt": "чек", - "remove": "Видалити", - "removeTitle": "Видалити витрату?", - "removeBody": "Це видалить витрату та її чек із розгортання.", - "cancel": "Скасувати", - "saved": "Витрату збережено.", - "amountInvalid": "Введіть суму, наприклад 42.50.", - "photoDenied": "Дозвольте доступ до камери або фото, щоб додати чек." } }, "protocols": { @@ -1662,6 +1737,7 @@ "background_location": "Місцезнаходження у фоні", "contact_us": "Зв'яжіться з нами", "current_unit": "Поточний підрозділ", + "custom": "Власний", "english": "Англійська", "enter_password": "Введіть пароль", "enter_server_url": "Введіть URL API Resgrid (напр. https://api.resgrid.com)", @@ -1772,6 +1848,7 @@ "station_destination_enabled": "Може реагувати на станції", "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Станції", + "status_queued": "Статус збережено на цьому пристрої. Його буде надіслано автоматично.", "status_saved_successfully": "Статус успішно збережено!" }, "tabs": { @@ -1903,80 +1980,5 @@ "unknown": "Невідомий" } }, - "welcome": "Ласкаво просимо до додатку Resgrid Unit", - "inventory": { - "title": "Майно", - "titleFor": "Майно · {{name}}", - "selectUnit": "Спершу виберіть свій підрозділ.", - "unnamedLocation": "Відсік", - "unnamedItem": "Позиція", - "countName": "Перевірка {{name}} {{date}}", - "notMigrated": "Облік майна налаштовується для вашої організації.", - "disabled": "Облік майна вимкнено для вашої організації.", - "noLocations": "За цим підрозділом не закріплено майна.", - "startCount": "Перерахувати майно", - "wholeUnit": "Увесь підрозділ: {{name}}", - "pickLocation": "Де рахувати", - "startHelp": "Рахуйте те, що фактично є на техніці. Розбіжності буде проведено після завершення.", - "start": "Почати перерахунок", - "cannotCount": "Для перерахунку потрібен дозвіл на коригування майна цього підрозділу.", - "counts": "Останні перерахунки", - "count": "Перерахунок майна", - "empty": "Тут нічого немає.", - "serial": "Серійний № {{serial}}", - "quantity": "× {{quantity}}", - "back": "Назад", - "completeTitle": "Завершити перерахунок?", - "completeBody": "{{count}} позицій відрізняються від очікуваного. Завершення проведе розбіжності.", - "completeNoChanges": "Усе відповідає очікуваному.", - "cancel": "Скасувати", - "complete": "Завершити", - "cancelTitle": "Скасувати цей перерахунок?", - "cancelBody": "Нічого з пораховано не буде проведено.", - "keep": "Рахувати далі", - "cancelCount": "Скасувати перерахунок", - "progress": "Пораховано {{counted}} з {{total}}", - "differences": "{{count}} розбіжностей", - "awaitingWitness": "Змінилася підконтрольна позиція; друга особа має засвідчити перерахунок у вебі.", - "completed": "Перерахунок завершено.", - "expected": "Очікується {{quantity}} {{unit}}", - "present": "Є", - "missing": "Відсутнє", - "less": "На одну менше", - "more": "На одну більше", - "matches": "Збігається", - "save": "Зберегти прогрес", - "countStatus": { - "0": "Триває", - "1": "Очікує свідка", - "2": "Завершено", - "3": "Скасовано" - }, - "assetStatus": { - "0": "У службі", - "1": "Видано", - "2": "У ремонті", - "3": "Пошкоджено", - "4": "Втрачено", - "5": "Використано", - "6": "Списано" - }, - "errors": { - "retry": "Не вдалося зв'язатися із сервером. Спробуйте ще раз.", - "locked": "Розблокуйте захищені дані, щоб продовжити.", - "PermissionRequired": "У вас немає дозволу на цю дію.", - "MembershipRequired": "Ви не є активним членом цієї організації.", - "InventoryDisabled": "Облік майна вимкнено для вашої організації.", - "MigrationRequired": "Облік майна налаштовується для вашої організації.", - "CountScopeTooLarge": "Забагато позицій для одного перерахунку. Рахуйте по одному відсіку.", - "CountEmpty": "Тут нічого рахувати.", - "CountSnapshotChanged": "Майно підрозділу змінилося під час перерахунку. Скасуйте й почніть знову.", - "CountStateConflict": "Цей перерахунок змінено на іншому пристрої. Відкрийте його знову.", - "ReturnAssetFirst": "Позицію, позначену відсутньою, досі видано комусь. Спершу оформіть повернення у вебі.", - "ProtectedDataRequired": "Розблокуйте захищені дані, щоб продовжити.", - "CountAlreadyExists": "Такий перерахунок уже існує.", - "LocationUnavailable": "Це місце недоступне.", - "InvalidCount": "Перевірте пораховані значення та спробуйте ще раз." - } - } + "welcome": "Ласкаво просимо до додатку Resgrid Unit" } diff --git a/yarn.lock b/yarn.lock index 7cfd4716..b9be1f45 100644 --- a/yarn.lock +++ b/yarn.lock @@ -723,7 +723,7 @@ "@babel/helper-create-class-features-plugin" "^7.27.1" "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-class-properties@^7.27.1": +"@babel/plugin-transform-class-properties@^7.28.6": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz#034897b8a21beec163332fac2de235b14409abdf" integrity sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA== @@ -751,7 +751,7 @@ "@babel/helper-replace-supers" "^7.27.1" "@babel/traverse" "^7.28.4" -"@babel/plugin-transform-classes@^7.28.4": +"@babel/plugin-transform-classes@^7.28.6": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz#61d3e5aaae0c838acc3204d9db7c8dc05c25815b" integrity sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g== @@ -832,7 +832,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-nullish-coalescing-operator@^7.27.1": +"@babel/plugin-transform-nullish-coalescing-operator@^7.28.6": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz#8a54cdf88c3f50433a6173117a286195b67714cc" integrity sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg== @@ -865,7 +865,7 @@ "@babel/helper-plugin-utils" "^7.27.1" "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" -"@babel/plugin-transform-optional-chaining@^7.27.1": +"@babel/plugin-transform-optional-chaining@^7.28.6": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz#b84a1b574b3c73001023092567e16c492b720e51" integrity sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ== @@ -1016,7 +1016,7 @@ "@babel/plugin-transform-modules-commonjs" "^7.27.1" "@babel/plugin-transform-typescript" "^7.27.1" -"@babel/preset-typescript@^7.27.1": +"@babel/preset-typescript@^7.28.5": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz#de9be1f47b785c979ec7b3a71f4cd8bae5267b62" integrity sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ== @@ -1586,36 +1586,36 @@ resolved "https://registry.yarnpkg.com/@expo-google-fonts/material-symbols/-/material-symbols-0.4.42.tgz#db6c5420b6060aee755ead226dc5dbd431efddf2" integrity sha512-KZmHZRcthJ3KFZZlpzHjopA9guZgWR9fb3uVZlTR0BNlvG2pw1bnYBCpkze2PB0vRllwGhAM7lWXsfmcWCbXYg== -"@expo/cli@^56.1.24": - version "56.1.24" - resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-56.1.24.tgz#7cf3520b95f1f9a521fce390d5fd1bc160dd595a" - integrity sha512-zWx4kHuZQrq1yz/uF6ADeXVvzG8N2gUj/Zf7eF6L8eALvEeX0uCBDmpdo5Yj/fY4sLo1N2VEH62StDRqnnSOxg== +"@expo/cli@^57.0.27": + version "57.0.27" + resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-57.0.27.tgz#68573b46121c03e326f196c4c9fcad3336f5eb2f" + integrity sha512-Jauk4chxmpVG5ElrMLTCwjFP20jbYU7PB4l3LYV8j+4e/3HU5jdl7qTQ4eVy1AaDUE9Ia2NCQ5A9kar2V1gaLg== dependencies: "@expo/code-signing-certificates" "^0.0.6" - "@expo/config" "~56.0.13" - "@expo/config-plugins" "~56.0.15" + "@expo/config" "~57.0.9" + "@expo/config-plugins" "~57.0.9" "@expo/devcert" "^1.2.1" - "@expo/env" "~2.3.1" - "@expo/image-utils" "^0.10.4" - "@expo/inline-modules" "^0.0.15" - "@expo/json-file" "^10.2.0" - "@expo/log-box" "^56.0.14" - "@expo/metro" "~56.0.0" - "@expo/metro-config" "~56.0.18" - "@expo/metro-file-map" "^56.0.3" - "@expo/osascript" "^2.6.0" - "@expo/package-manager" "^1.12.1" - "@expo/plist" "^0.7.0" - "@expo/prebuild-config" "^56.0.22" - "@expo/require-utils" "^56.1.6" - "@expo/router-server" "^56.0.18" - "@expo/schema-utils" "^56.0.2" + "@expo/env" "~2.4.3" + "@expo/image-utils" "^0.11.5" + "@expo/inline-modules" "^0.1.7" + "@expo/json-file" "^11.0.1" + "@expo/log-box" "^57.0.4" + "@expo/metro" "~56.0.2" + "@expo/metro-config" "~57.0.12" + "@expo/metro-file-map" "^57.0.3" + "@expo/osascript" "^2.7.1" + "@expo/package-manager" "^1.13.1" + "@expo/plist" "^0.8.1" + "@expo/prebuild-config" "^57.0.16" + "@expo/require-utils" "^57.0.5" + "@expo/router-server" "^57.0.11" + "@expo/schema-utils" "^57.0.2" "@expo/spawn-async" "^1.8.0" "@expo/ws-tunnel" "^2.0.0" "@expo/xcpretty" "^4.4.4" - "@react-native/dev-middleware" "0.85.3" + "@react-native/dev-middleware" "0.86.3" accepts "^1.3.8" - agent-cli-detector "^0.1.2" + agent-cli-detector "0.1.7" arg "^5.0.2" bplist-creator "0.1.0" bplist-parser "^0.3.1" @@ -1624,8 +1624,8 @@ compression "^1.7.4" connect "^3.7.0" debug "^4.3.4" - dnssd-advertise "^1.1.6" - expo-server "^56.0.6" + dnssd-advertise "^1.1.4" + expo-server "^57.0.3" fetch-nodeshim "^0.4.10" getenv "^2.0.0" glob "^13.0.0" @@ -1658,34 +1658,15 @@ dependencies: node-forge "^1.3.3" -"@expo/config-plugins@^56.0.15", "@expo/config-plugins@~56.0.15": - version "56.0.15" - resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-56.0.15.tgz#d77cfe75551dff03e0a956f02d1180b64c615c86" - integrity sha512-MsSko6coyBWgx7oWFFETxAZZtGJlYykCuCKAGxYgzfT1leUxPVNg1FuGD9moPObDxGadB9l6GJeDuaM2tuCmsQ== - dependencies: - "@expo/config-types" "^56.0.7" - "@expo/json-file" "~10.2.0" - "@expo/plist" "^0.7.0" - "@expo/require-utils" "^56.1.6" - "@expo/sdk-runtime-versions" "^1.0.0" - chalk "^4.1.2" - debug "^4.3.5" - getenv "^2.0.0" - glob "^13.0.0" - semver "^7.5.4" - slugify "^1.6.6" - xcode "^3.0.1" - xml2js "0.6.0" - -"@expo/config-plugins@~56.0.14": - version "56.0.14" - resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-56.0.14.tgz#be7eae1f0da388b65a57d1fda3444bb5bca2c162" - integrity sha512-pdjBwH67BF1dnim4N/OxgBshhil27eu3C9uuhwzzK2Bnzl6bIcdt9WjEVuMXluFnEpWe0ws5sxE8NvBR5Ms7bQ== +"@expo/config-plugins@^57.0.9", "@expo/config-plugins@~57.0.9": + version "57.0.9" + resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-57.0.9.tgz#3fa42fb565f3662e518f0accc1653a9ac25f73f3" + integrity sha512-hHgfL1avkCdEvDSw7IwlKwRYYNgcxzbNNMIk6W6lTkJpY0MajinAfeJUS0J+wPCsjUfGbVqOJM+XhPaO5ulUxg== dependencies: - "@expo/config-types" "^56.0.7" - "@expo/json-file" "~10.2.0" - "@expo/plist" "^0.7.0" - "@expo/require-utils" "^56.1.6" + "@expo/config-types" "^57.0.2" + "@expo/json-file" "~11.0.1" + "@expo/plist" "^0.8.1" + "@expo/require-utils" "^57.0.5" "@expo/sdk-runtime-versions" "^1.0.0" chalk "^4.1.2" debug "^4.3.5" @@ -1696,20 +1677,20 @@ xcode "^3.0.1" xml2js "0.6.0" -"@expo/config-types@^56.0.7": - version "56.0.7" - resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-56.0.7.tgz#2dca8f469f5af29a772933a18e28dd8f97afd53a" - integrity sha512-V7bxawNsNned/yMppAHdisIOxniZXgPKRWpIUiQOQBs45/A5MBd2gfDM4Ecq5gnbilnQUTaI6Zxn6JcW7L3TAA== +"@expo/config-types@^57.0.2": + version "57.0.2" + resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-57.0.2.tgz#127da99326399e014b63cf23d71220719f1336a1" + integrity sha512-ewW08OonrcRIsRKIlFvvcmmafE5zemb1ocu3HkNwtVPyRtj2w42pZCAkMIROYpcVBaPnc3mDT9UZDzwXWC3i6g== -"@expo/config@^56.0.0", "@expo/config@~56.0.13": - version "56.0.13" - resolved "https://registry.yarnpkg.com/@expo/config/-/config-56.0.13.tgz#ab435bcc4d038e3fa8f0283b982ead9c102a8189" - integrity sha512-Fi1J1mjD3O2qDQsUPTHhsFXoHduZ5gSkWvCeyKxyp3R9fXgXrmhbpo6T+lZJG8x2t6xL1P/SIc6bLdsTJkKaOg== +"@expo/config@^57.0.0", "@expo/config@~57.0.9": + version "57.0.9" + resolved "https://registry.yarnpkg.com/@expo/config/-/config-57.0.9.tgz#21528ed96a49e6bad7a2988628b9026c256ee4fe" + integrity sha512-dmzlKraIFxa7wLwV6K7WzI8jp6QZpW6Mc5mGjLimJUFjzh4uQdYaT3m3plEutM5yxBoBEwqzks7l+I/ljCbxAQ== dependencies: - "@expo/config-plugins" "~56.0.14" - "@expo/config-types" "^56.0.7" - "@expo/json-file" "^10.2.0" - "@expo/require-utils" "^56.1.6" + "@expo/config-plugins" "~57.0.9" + "@expo/config-types" "^57.0.2" + "@expo/json-file" "^11.0.1" + "@expo/require-utils" "^57.0.5" deepmerge "^4.3.1" getenv "^2.0.0" glob "^13.0.0" @@ -1725,47 +1706,38 @@ "@expo/sudo-prompt" "^9.3.1" debug "^3.1.0" -"@expo/devtools@~56.0.2": - version "56.0.2" - resolved "https://registry.yarnpkg.com/@expo/devtools/-/devtools-56.0.2.tgz#e1284c713d4d9fb574b2c231de915c3a283d3b0b" - integrity sha512-ANl4kPdbe0/HQYWkDEN79S6bQhI+i/ZCnPxuC853pPsB4svhINC7Ku9lmGOKPsUUWWnrHg1spkDGQBZ4sD6JxQ== +"@expo/devtools@~57.0.1": + version "57.0.1" + resolved "https://registry.yarnpkg.com/@expo/devtools/-/devtools-57.0.1.tgz#81a0e976f38c30774bfeec2f306f17bcdb99998a" + integrity sha512-GyUf+wFNkbttaX0jR7MZa9bm77U0IrLg6d2AjpxdyoXw/w4abHoXG0oFufwLMgP9zLTd5+Ct4X/ffNUTnlzZgg== dependencies: chalk "^4.1.2" -"@expo/dom-webview@^56.0.6", "@expo/dom-webview@~56.0.6": - version "56.0.6" - resolved "https://registry.yarnpkg.com/@expo/dom-webview/-/dom-webview-56.0.6.tgz#0ad1b7ca2070a273c16dd751a0071c0678a22df9" - integrity sha512-DQY3Tj5nrPbpIfEQiD9xbyEFTu25yEORa02Eyzl5rXszDzxDT4VkZDHkyErNQOyE1qjublJHNFhE5bDm4tRfqA== - -"@expo/env@^2.3.1": - version "2.4.2" - resolved "https://registry.yarnpkg.com/@expo/env/-/env-2.4.2.tgz#c180fd577e664b6090029157ae0e1b7ddfd66fac" - integrity sha512-28pqaEqwnmLduZ00Pq9HkSzE5wbj1MTwp5/n8nm8rD8MCjR9eUnVOwmNksPI3Be2ReAPO/DbPn1puy0mvoocsQ== - dependencies: - chalk "^4.0.0" - debug "^4.3.4" - getenv "^2.0.0" +"@expo/dom-webview@^57.0.1", "@expo/dom-webview@~57.0.1": + version "57.0.1" + resolved "https://registry.yarnpkg.com/@expo/dom-webview/-/dom-webview-57.0.1.tgz#e41e9ab9b691046c944ef2d0cd874cf316894097" + integrity sha512-lAKsME4SAq+8sf56oN0DX5TBYyruupoRxbWbD2xf9RnKY8y6x8eb9LCE5pxSN0qyWdqnp+0wmyWzDkKboThKAw== -"@expo/env@~2.3.1": - version "2.3.1" - resolved "https://registry.yarnpkg.com/@expo/env/-/env-2.3.1.tgz#098629f2422d50a9ce5513c2df8954a928d19f59" - integrity sha512-JvBdZa5OkTd+dGEtt3fLW9OF6RlIp9SNu9VyMSiWPV5szMDTSAYbX8Uj3cRvZcU1zzaRbqnTSsHk2AE8WXHfxQ== +"@expo/env@^2.4.3", "@expo/env@~2.4.3": + version "2.4.3" + resolved "https://registry.yarnpkg.com/@expo/env/-/env-2.4.3.tgz#cba412d1cf19374bedd83106b32166a12862667c" + integrity sha512-M1NXeZCA1mkMkYOyIe7PlyRX0/jqFtMoJgyblnlq/vpCRfmueFT7RnGSQG8uEFDF5WHOFGijAQ3fogPh3/n5Ng== dependencies: chalk "^4.0.0" debug "^4.3.4" getenv "^2.0.0" -"@expo/expo-modules-macros-plugin@0.2.2": - version "0.2.2" - resolved "https://registry.yarnpkg.com/@expo/expo-modules-macros-plugin/-/expo-modules-macros-plugin-0.2.2.tgz#b70b7022369df49f7fcc9a4125f08ca4ee01e26b" - integrity sha512-4IMzPDIo/VOXREQjsJtliSfqYVZvfzU2SLFS/9sKMWF848S8CHx+e/E+Vf0TcMvpWCCKX5umyqxb13KJJ+YUzg== +"@expo/expo-modules-macros-plugin@0.6.1": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@expo/expo-modules-macros-plugin/-/expo-modules-macros-plugin-0.6.1.tgz#5581bbe09e0aedb39ea787a9960eb0a9ee07d2ff" + integrity sha512-cpsLZE4rqkc1Y3eZTkxB98jrqY1YXgetmtxFt8q89jBRmk3quRuk1BZo+VcnCSObZardjg99r1k5xijEMONFGA== -"@expo/fingerprint@^0.19.9": - version "0.19.9" - resolved "https://registry.yarnpkg.com/@expo/fingerprint/-/fingerprint-0.19.9.tgz#b8e861e83deb4362d1476bc52d2a00d32867a4ae" - integrity sha512-gphqoJt5dXPfq6iWFwSLKFcUi6gDnBy9GM4XuKkKbjQEqGyBH6v2iS16Oht6xfCBWkRSy28CNFsm8lKmoJVP2g== +"@expo/fingerprint@^0.20.13": + version "0.20.13" + resolved "https://registry.yarnpkg.com/@expo/fingerprint/-/fingerprint-0.20.13.tgz#244496407df37a41bdb030261ff7db61aed2b48b" + integrity sha512-h1x+w+JpNRu88a/5SMZRzJs9YDGR/f7R8ApCr7OdenhqmJ00hFxQMzvxajDLL+1P2Y2g/ND0xksusqvBqWSW5Q== dependencies: - "@expo/env" "^2.3.1" + "@expo/env" "^2.4.3" "@expo/spawn-async" "^1.8.0" arg "^5.0.2" chalk "^4.1.2" @@ -1782,12 +1754,12 @@ resolved "https://registry.yarnpkg.com/@expo/html-elements/-/html-elements-0.10.1.tgz#ec2625370cf1d4cb78efa954df45d422532d5ab6" integrity sha512-3PTmtkV15D7+lykXVtvkH1jQ5Y6JE+e3zCaoMMux7z2cSLGQUNwDEUwG37gew3OEB1/E4/SEWgjvg8m7E6/e2Q== -"@expo/image-utils@^0.10.4": - version "0.10.4" - resolved "https://registry.yarnpkg.com/@expo/image-utils/-/image-utils-0.10.4.tgz#0f0b38e465492a0bf25f9ceb0fe780cf1e452821" - integrity sha512-QGdC9eeNyF7vozMV77wQQwX4z3ZJK8D707WSTcBTE/XI19T41g5NtGVM/BMqb1oBV+zSZlNwpOA6bXbbvtC2UA== +"@expo/image-utils@^0.11.5": + version "0.11.5" + resolved "https://registry.yarnpkg.com/@expo/image-utils/-/image-utils-0.11.5.tgz#47cd85c9d09ab2fb155823da62b71b9ae0f7ffa7" + integrity sha512-KPQBTpmpAfy/Vu9y4wPW808/qtZxjYmyJg8cm2QCPAupp+qEWA3b5zmk0ulOwQ9OgeHxuCPgUqWgkwHFo7UsrQ== dependencies: - "@expo/require-utils" "^56.1.6" + "@expo/require-utils" "^57.0.5" "@expo/spawn-async" "^1.8.0" chalk "^4.0.0" getenv "^2.0.0" @@ -1795,22 +1767,14 @@ parse-png "^2.1.0" semver "^7.6.0" -"@expo/inline-modules@^0.0.15": - version "0.0.15" - resolved "https://registry.yarnpkg.com/@expo/inline-modules/-/inline-modules-0.0.15.tgz#ee1a0e2de5822c6f0bd2aa16dad8c577e3c12ea9" - integrity sha512-EtGaKjzsNF0Jp3Alcn6aKX/QOPEdf1P6i5qTm91mJBmJ4lcWbQkyPrCVNEZHjp989w7c4exd8myA6c0whHcghg== +"@expo/inline-modules@^0.1.7": + version "0.1.7" + resolved "https://registry.yarnpkg.com/@expo/inline-modules/-/inline-modules-0.1.7.tgz#43bc725ce4993496693040d30c36c38fe542cdd8" + integrity sha512-Bz/khd1gIJqDkje7t5ejD5e9jFbm4xEJzWSwRKadRo6gruepbw1xJ3Eb+e58OS8fY1ZNQYjzZbspnuph67sN0g== dependencies: - "@expo/config-plugins" "~56.0.14" + "@expo/config-plugins" "~57.0.9" -"@expo/json-file@^10.2.0", "@expo/json-file@~10.2.0": - version "10.2.0" - resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-10.2.0.tgz#a63fca4445f2457a60407a3d76da8bfd0221e9a4" - integrity sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ== - dependencies: - "@babel/code-frame" "^7.20.0" - json5 "^2.2.3" - -"@expo/json-file@^11.0.1": +"@expo/json-file@^11.0.1", "@expo/json-file@~11.0.1": version "11.0.1" resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-11.0.1.tgz#dab8f6e5ff78e7c33a8e49a48722ce972306036c" integrity sha512-zxHWj4MKKMAL29ZQSY/Fssx4Thluk40JmuGNaeS078wy/NhlFhnVi+rHHunulE3xJAJ0CM73m8VK2+GkF9eRwQ== @@ -1818,36 +1782,36 @@ "@babel/code-frame" "^7.20.0" json5 "^2.2.3" -"@expo/local-build-cache-provider@^56.0.11": - version "56.0.11" - resolved "https://registry.yarnpkg.com/@expo/local-build-cache-provider/-/local-build-cache-provider-56.0.11.tgz#b15402ea62c08888cf9c3a4427afece17e61e00f" - integrity sha512-2SfYrb5vBs0Ra3bZfpvtJXJbS1oTSwo9DmG4bpZUW/sKUT0+kXHkPI9uGiUa687SIOEQuFBinylD3C9Z2lma3Q== +"@expo/local-build-cache-provider@^57.0.8": + version "57.0.8" + resolved "https://registry.yarnpkg.com/@expo/local-build-cache-provider/-/local-build-cache-provider-57.0.8.tgz#8d64ce4045734dbe735aa20b1cd0209347725a92" + integrity sha512-SEdE0pAQrr90bRh3MNR0ZuwoIBw390cYdFgbn7Vk0Mtm9EHaBfP7kYj+2hXnXZJgOEm3/JMtfoD3rV7rWA9FGg== dependencies: - "@expo/config" "~56.0.13" + "@expo/config" "~57.0.9" chalk "^4.1.2" -"@expo/log-box@^56.0.14": - version "56.0.14" - resolved "https://registry.yarnpkg.com/@expo/log-box/-/log-box-56.0.14.tgz#f95ea1c8de5b0ed9674a5dcf852ff06761eeaa11" - integrity sha512-3Eadcxz0J2NESG2iKe8DnAggsRCWqY0mBLhgQPNzClPVjS6o4NyD0F8kuOvWNngFwpJBC08HhY2o8P1mgSgeJg== +"@expo/log-box@^57.0.4": + version "57.0.4" + resolved "https://registry.yarnpkg.com/@expo/log-box/-/log-box-57.0.4.tgz#373148b27267f70fa093516a46c9abbbd0f0df0a" + integrity sha512-IxwS9s1L2muj8mj8AQSuiy7u8OFJdc02NRFo2me/Tj6DiaeG5SREqmpBE4rQpR2cadqSg5jl8Qab8Cjie616dg== dependencies: - "@expo/dom-webview" "^56.0.6" + "@expo/dom-webview" "^57.0.1" anser "^1.4.9" stacktrace-parser "^0.1.10" -"@expo/metro-config@~56.0.18": - version "56.0.18" - resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-56.0.18.tgz#74d805e087750ce1296b23411525f304cb6c59a2" - integrity sha512-X7ephejQE7YjL4401ObdfpluL0enEmLK4ZjMLJrhtkFh0ksZI7jQMvUHxLXZzCHD0SlMXa/RdB9abnWsOj1E5A== +"@expo/metro-config@~57.0.12": + version "57.0.12" + resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-57.0.12.tgz#8955eb416ac07c86c5b9f0bd1de7bc2cf1486bab" + integrity sha512-S62Lrq35HZqBFD55423pmWb8PjaiR/W02zQC1uECBmw1vTN8WZaFz4TJ0i21EeJzwfebMb9MLxL8JZ102Z6VbA== dependencies: "@babel/code-frame" "^7.20.0" "@babel/core" "^7.20.0" "@babel/generator" "^7.20.5" - "@expo/config" "~56.0.13" - "@expo/env" "~2.3.1" - "@expo/json-file" "~10.2.0" - "@expo/metro" "~56.0.0" - "@expo/require-utils" "^56.1.6" + "@expo/config" "~57.0.9" + "@expo/env" "~2.4.3" + "@expo/json-file" "~11.0.1" + "@expo/metro" "~56.0.2" + "@expo/require-utils" "^57.0.5" "@expo/spawn-async" "^1.8.0" "@jridgewell/gen-mapping" "^0.3.13" "@jridgewell/remapping" "^2.3.5" @@ -1857,17 +1821,17 @@ debug "^4.3.2" getenv "^2.0.0" glob "^13.0.0" - hermes-parser "^0.33.3" + hermes-parser "^0.36.0" jsc-safe-url "^0.2.4" lightningcss "^1.30.1" picomatch "^4.0.4" postcss "^8.5.14" resolve-from "^5.0.0" -"@expo/metro-file-map@^56.0.3": - version "56.0.3" - resolved "https://registry.yarnpkg.com/@expo/metro-file-map/-/metro-file-map-56.0.3.tgz#d51ebd5bbf2677e5378b2636bad756e5920882d9" - integrity sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w== +"@expo/metro-file-map@^57.0.3": + version "57.0.3" + resolved "https://registry.yarnpkg.com/@expo/metro-file-map/-/metro-file-map-57.0.3.tgz#1ed09151ac17f594fc08051de15894cdfef07503" + integrity sha512-1OXy+uPYY5uc7Tm4VBsd2NRn+3wHhqeqNuEO/Xo4kmYgv8FjYgUAc+bUXON9FpC2ikcLn4EVlGM9ce2exx9Mlg== dependencies: debug "^4.3.4" fb-watchman "^2.0.2" @@ -1876,45 +1840,45 @@ micromatch "^4.0.4" walker "^1.0.8" -"@expo/metro-runtime@^56.0.20", "@expo/metro-runtime@~56.0.20": - version "56.0.20" - resolved "https://registry.yarnpkg.com/@expo/metro-runtime/-/metro-runtime-56.0.20.tgz#93358aad011ffa87d5c70f233c5ae093a54d967e" - integrity sha512-ICwOVi5rwcQQn3/euXytnhS4YoU6wenKchCx8HFSvl9vKx0w6Tv4IE9oWE09UT6rTRtmVavRHqfPTM8eaNBLtA== +"@expo/metro-runtime@^57.0.16", "@expo/metro-runtime@~57.0.16": + version "57.0.16" + resolved "https://registry.yarnpkg.com/@expo/metro-runtime/-/metro-runtime-57.0.16.tgz#01570c6564c53e2529f4d4c81ea6c3dd862b8e88" + integrity sha512-x43WgZoICpp37BhDV7vixe0bNxbVPPGaBLHYc98ZJ8MU+UC2dk39Y2XvkfYMRRbAUSIkCIJiwdERnptlPg0Ckw== dependencies: - "@expo/log-box" "^56.0.14" + "@expo/log-box" "^57.0.4" anser "^1.4.9" pretty-format "^29.7.0" stacktrace-parser "^0.1.10" whatwg-fetch "^3.0.0" -"@expo/metro@~56.0.0": - version "56.0.0" - resolved "https://registry.yarnpkg.com/@expo/metro/-/metro-56.0.0.tgz#7ed3d4b2968123054223eae9b98918561f6f6128" - integrity sha512-5gIgQHtEpjjvsjKfVtIv23a98LLRV0/y07PDShEwYSytAMlE3FSF8RHXqtHc1sUJL6dn7hnuIBpIbrLXXuVi0A== - dependencies: - metro "0.84.4" - metro-babel-transformer "0.84.4" - metro-cache "0.84.4" - metro-cache-key "0.84.4" - metro-config "0.84.4" - metro-core "0.84.4" - metro-file-map "0.84.4" - metro-minify-terser "0.84.4" - metro-resolver "0.84.4" - metro-runtime "0.84.4" - metro-source-map "0.84.4" - metro-symbolicate "0.84.4" - metro-transform-plugins "0.84.4" - metro-transform-worker "0.84.4" - -"@expo/osascript@^2.6.0": +"@expo/metro@~56.0.2": + version "56.0.2" + resolved "https://registry.yarnpkg.com/@expo/metro/-/metro-56.0.2.tgz#f5371f203c00788c7037897616d5efc9efeeb74a" + integrity sha512-Ld5AeYMCCDa8bLeWhfuLbZFFjlV3f6ORqyPz2glGh6RltIngMuLf9BTC2yvHFjkKuGxL5SynijmA8xmNNWn5iA== + dependencies: + metro "0.84.5" + metro-babel-transformer "0.84.5" + metro-cache "0.84.5" + metro-cache-key "0.84.5" + metro-config "0.84.5" + metro-core "0.84.5" + metro-file-map "0.84.5" + metro-minify-terser "0.84.5" + metro-resolver "0.84.5" + metro-runtime "0.84.5" + metro-source-map "0.84.5" + metro-symbolicate "0.84.5" + metro-transform-plugins "0.84.5" + metro-transform-worker "0.84.5" + +"@expo/osascript@^2.7.1": version "2.7.1" resolved "https://registry.yarnpkg.com/@expo/osascript/-/osascript-2.7.1.tgz#1880dcbaf79cde9631e0e68f461f05c8f0542b88" integrity sha512-Zn03EX6In7ts2lPUW2ESUSkEhEWQN1qqsiXjadtZMJOuZRkMiAg1ZQHuvz9DjByDWNJ2pBwAGyrts9lj9k389g== dependencies: "@expo/spawn-async" "^1.8.0" -"@expo/package-manager@^1.12.1": +"@expo/package-manager@^1.13.1": version "1.13.1" resolved "https://registry.yarnpkg.com/@expo/package-manager/-/package-manager-1.13.1.tgz#a6ce56c602a70e33ed376525dd100945e19006f9" integrity sha512-y/K+CaYYpZpNGZhSX4HyLT/vyIunFjNfyoxNysPBCefeLKI/VCx6f9LNPzrxayr3rCYO5bl9O8H+HRQK265Nkg== @@ -1926,51 +1890,51 @@ ora "^3.4.0" resolve-workspace-root "^2.0.0" -"@expo/plist@^0.7.0": - version "0.7.0" - resolved "https://registry.yarnpkg.com/@expo/plist/-/plist-0.7.0.tgz#32ceacaa64a6422ebba06b0cc4a4ca6e7f7e882a" - integrity sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q== +"@expo/plist@^0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@expo/plist/-/plist-0.8.1.tgz#8f024f23d7b9cf45cfff4a6f8871d1c107a8833c" + integrity sha512-3gTReGIUm0oRaMClsAJYxBnVPCl6fVpsl8HS+DTVxDhW4GyVyxg9E/Znm3BvcHtUJ51RJJI14pC1wvrNilCRHw== dependencies: "@xmldom/xmldom" "^0.8.8" base64-js "^1.5.1" xmlbuilder "^15.1.1" -"@expo/prebuild-config@^56.0.22": - version "56.0.22" - resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-56.0.22.tgz#a9c805fb8e2a2df50df48ad612aadc896ad73ea8" - integrity sha512-U0/7zZWFj/6kMSwNHPfl7QGfM3VyoONPC5TjFXh1gyYcdE1sVBZv5pJH5uRC0DgRNqyeiHvU429C5snIAZ5x2w== - dependencies: - "@expo/config" "~56.0.13" - "@expo/config-plugins" "~56.0.15" - "@expo/config-types" "^56.0.7" - "@expo/image-utils" "^0.10.4" - "@expo/json-file" "^10.2.0" - "@react-native/normalize-colors" "0.85.3" +"@expo/prebuild-config@^57.0.16": + version "57.0.16" + resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-57.0.16.tgz#64e7697d93ed332e60c85730cd8d863619c177cf" + integrity sha512-QA8oMZ7y70kjOzw+abhRl9uYC8Nu/abeUTHr0YVxb3pgIC14gc7w543bYtI+5gEDI3q55jKUViIauC76QvZN1w== + dependencies: + "@expo/config" "~57.0.9" + "@expo/config-plugins" "~57.0.9" + "@expo/config-types" "^57.0.2" + "@expo/image-utils" "^0.11.5" + "@expo/json-file" "^11.0.1" + "@react-native/normalize-colors" "0.86.3" debug "^4.3.1" - expo-modules-autolinking "~56.0.22" + expo-modules-autolinking "~57.0.13" resolve-from "^5.0.0" semver "^7.6.0" -"@expo/require-utils@^56.1.6": - version "56.1.6" - resolved "https://registry.yarnpkg.com/@expo/require-utils/-/require-utils-56.1.6.tgz#3bf38b802451e8737b6a40f77deaa40c6a5ebfbc" - integrity sha512-e68r6X+c7yPxJP0gac/CaJc/RCHeLB2Bf4MyCuqfNM4YXfl1ONmQbQlRL0RbwBfEHn6A6UZKj+5UX9j0Im646g== +"@expo/require-utils@^57.0.5": + version "57.0.5" + resolved "https://registry.yarnpkg.com/@expo/require-utils/-/require-utils-57.0.5.tgz#bde8d170b0b53fa1cd77aa73a305e5a16f4229a3" + integrity sha512-kTAXj9lDFEIPMsbAOGCGbjBbMF0oi7CqkYM79KOX0DDD9wSwXmlKL1z2h8OwsrBf7mbOo2DjlRvZu4BEjrIxGw== dependencies: "@babel/code-frame" "^7.20.0" "@babel/core" "^7.25.2" "@babel/plugin-transform-modules-commonjs" "^7.24.8" -"@expo/router-server@^56.0.18": - version "56.0.18" - resolved "https://registry.yarnpkg.com/@expo/router-server/-/router-server-56.0.18.tgz#df736583ca8a7687893bb12b03d6fcae7edb09a5" - integrity sha512-3Ko9M9yIu9pcWcNik55JT3DYrODumIFkrRN6xbL+HtUFgiQp6N02gt6LngPRu0r7YKWYvHU12rsM3MDtucs63Q== +"@expo/router-server@^57.0.11": + version "57.0.11" + resolved "https://registry.yarnpkg.com/@expo/router-server/-/router-server-57.0.11.tgz#54456fefa3085413db15da66f1e5e258286bc9e0" + integrity sha512-0QxbqEkceb1WDeu5VqNdnTPVHlm3U183F7Y1t5LMohmGRAkHVL0PB4tCHBvEqKpLzOzHVObw3reGkJ1tX48h8Q== dependencies: debug "^4.3.4" -"@expo/schema-utils@^56.0.2": - version "56.0.2" - resolved "https://registry.yarnpkg.com/@expo/schema-utils/-/schema-utils-56.0.2.tgz#c659ff01599117d49fece720c8387a0d4de52aa7" - integrity sha512-WcOH1E6rwxRqNwBzPOVhd5GBbMlS04+5n+ZMdhdwKOg/Kt3suV+F8F6An+z8mUJ8eSuMM3HD9Sj09t7vc/Xjkw== +"@expo/schema-utils@^57.0.2": + version "57.0.2" + resolved "https://registry.yarnpkg.com/@expo/schema-utils/-/schema-utils-57.0.2.tgz#abbce2902bd23a338691d600815a6093cc6f4403" + integrity sha512-fMu/jyN0l1Wzv7XkeWR4IYCx1M8ryui3FdBNGrWwbRgJ7EhxXxK8E2jxP2W3pbgUwUY0V3hG8+GyfCZwny+Lxw== "@expo/sdk-runtime-versions@^1.0.0": version "1.0.0" @@ -1989,10 +1953,10 @@ resolved "https://registry.yarnpkg.com/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz#0fd2813402a42988e49145cab220e25bea74b308" integrity sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw== -"@expo/ui@^56.0.26": - version "56.0.26" - resolved "https://registry.yarnpkg.com/@expo/ui/-/ui-56.0.26.tgz#7082a9e75fbc6467a5ca15792a89ed3b131766d0" - integrity sha512-FtGq/lDXcf2uo3x7pklvetnSYioriIFe85HMLAl72fsFfcUTkwYEPBDvq0iTI214peDTehuWXYiQi0oyuMsaIw== +"@expo/ui@^57.0.20": + version "57.0.20" + resolved "https://registry.yarnpkg.com/@expo/ui/-/ui-57.0.20.tgz#86d9f1404888a62d387a97f2205e75220b8f6ed8" + integrity sha512-0ChmuyWBwEy3JHMR5wDkJGyvkTR5zH3BTwjJrv3f2ymtamhpU9hD5uH4GsLoWuB6MJFOPTuchq4zByZbtgPyNw== dependencies: sf-symbols-typescript "^2.1.0" vaul "^1.1.2" @@ -3581,38 +3545,38 @@ resolved "https://registry.yarnpkg.com/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz#7064533a573e3539ec912f59c1f457371bf49dd9" integrity sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ== -"@react-native/assets-registry@0.85.3": - version "0.85.3" - resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.85.3.tgz#e28da0d296120b9677c3cb9f4ebc92b636ac987e" - integrity sha512-u9ZiYP23vA2IFtdFQFmetzSmk6SM0xgKIoiOsr1hXNHjHaLhOm+/Ph1ud57wX6+Dbwdzx8coJgnzSKL3W21PCg== +"@react-native/assets-registry@0.86.3": + version "0.86.3" + resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.86.3.tgz#c9fd9c0d709133a70738697c0033af83ffa1c21e" + integrity sha512-TDhgCZA4wjJg84d5A9swiOQYPIWSKEEVdg9IwMFZDupQzW/F3QoLUrfAJOcalgqTDA9/buTB8awhE3Whwg6u9Q== -"@react-native/babel-plugin-codegen@0.85.3": - version "0.85.3" - resolved "https://registry.yarnpkg.com/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.85.3.tgz#85584812e08e04b0b8f443ee732f787017eaf254" - integrity sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA== +"@react-native/babel-plugin-codegen@0.86.3": + version "0.86.3" + resolved "https://registry.yarnpkg.com/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.86.3.tgz#e5af8fffc8bdeedcc0019afc988e53c8cb18c207" + integrity sha512-O6Xza4JBGPIU8J7YbKTyBoYL4thpy8jMW/oaLDWdAyOwYHKIjK47pAL5HUEbOe2bWz2PEKjbYRF2ApkJv1ottQ== dependencies: "@babel/traverse" "^7.29.0" - "@react-native/codegen" "0.85.3" + "@react-native/codegen" "0.86.3" -"@react-native/codegen@0.85.3": - version "0.85.3" - resolved "https://registry.yarnpkg.com/@react-native/codegen/-/codegen-0.85.3.tgz#3168a42d240b66d1597f8e292ce71171fabb3b1c" - integrity sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA== +"@react-native/codegen@0.86.3": + version "0.86.3" + resolved "https://registry.yarnpkg.com/@react-native/codegen/-/codegen-0.86.3.tgz#7ef0a10bb98ba5004463cf8f66adb000dba1a598" + integrity sha512-Ux4jHi0fh+bdtVEcL0gaPLbY56V+SvFUDl/8sRAE1jdb4k+o7fT/4Nc29yz4X+qfjstkSqObQTMBGhdzxH9JvA== dependencies: "@babel/core" "^7.25.2" "@babel/parser" "^7.29.0" - hermes-parser "0.33.3" + hermes-parser "0.36.0" invariant "^2.2.4" nullthrows "^1.1.1" tinyglobby "^0.2.15" yargs "^17.6.2" -"@react-native/community-cli-plugin@0.85.3": - version "0.85.3" - resolved "https://registry.yarnpkg.com/@react-native/community-cli-plugin/-/community-cli-plugin-0.85.3.tgz#bc9008fe823bca059ab330861253e9be57f8cc46" - integrity sha512-fs85dmbIqNmtzEixDb0g+q6R3Vt4H9eAt8/inIZdDKfjN76+sUJA2r1nxODQ76bU23MrIbz8sI7KFBPaWk/zQw== +"@react-native/community-cli-plugin@0.86.3": + version "0.86.3" + resolved "https://registry.yarnpkg.com/@react-native/community-cli-plugin/-/community-cli-plugin-0.86.3.tgz#4e3dee7a018ac1e5cc91c6d00ca0b89f82281784" + integrity sha512-qSDL9LQc5mZSZPNczT95WU9YQuPzxBklgON9vLhhqfI0yWIwKInqFx88dQ/uiEXBtf0yossthaQIqA4Ml6bF6g== dependencies: - "@react-native/dev-middleware" "0.85.3" + "@react-native/dev-middleware" "0.86.3" debug "^4.4.0" invariant "^2.2.4" metro "^0.84.3" @@ -3620,28 +3584,28 @@ metro-core "^0.84.3" semver "^7.1.3" -"@react-native/debugger-frontend@0.85.3": - version "0.85.3" - resolved "https://registry.yarnpkg.com/@react-native/debugger-frontend/-/debugger-frontend-0.85.3.tgz#c8c66a5bb766b22466ffb0adaf0f0074bd38b482" - integrity sha512-uAu7rM5o/Np1zgp6fi5zM1sP1aB8DcS7DdOLcj/TkSutOAjkMqqd2lWt1/+3S7qXexRHVK5XcP+o3VXo4L/V0A== +"@react-native/debugger-frontend@0.86.3": + version "0.86.3" + resolved "https://registry.yarnpkg.com/@react-native/debugger-frontend/-/debugger-frontend-0.86.3.tgz#93fb484ed4bffb48d20a1f25f02323e1b9f3cabe" + integrity sha512-TQmeofQ0PcuylhhlleOeuzHYZfbrgm3gayXzowqUEzgRisTm1D40/J3ggqs7XkQi5HP5ZA3n8dHmKL9vIzPcsw== -"@react-native/debugger-shell@0.85.3": - version "0.85.3" - resolved "https://registry.yarnpkg.com/@react-native/debugger-shell/-/debugger-shell-0.85.3.tgz#ad755fc280942c240cd833916ddcf89a86a34b1f" - integrity sha512-/jRAaT9boiCttIcEwS02WPwYkUihqsjSaK/TMtHz05vT6uMgac9PaQt5kzBQLIABv5aEIa5gtrMmKVz49MjkjQ== +"@react-native/debugger-shell@0.86.3": + version "0.86.3" + resolved "https://registry.yarnpkg.com/@react-native/debugger-shell/-/debugger-shell-0.86.3.tgz#a2348ba5bae41fa2573a008383098d1cf0ef1cab" + integrity sha512-O4ds+J7xZfxkbih9T+cAGegBdvKSPKYJm/lDgC9CpEjFMkmzWTpVLU3Qsv9sqZuo58z+sGhIcJfPsCFFWHpqbQ== dependencies: cross-spawn "^7.0.6" debug "^4.4.0" fb-dotslash "0.5.8" -"@react-native/dev-middleware@0.85.3": - version "0.85.3" - resolved "https://registry.yarnpkg.com/@react-native/dev-middleware/-/dev-middleware-0.85.3.tgz#a5818ea674b0bbead230555697748b215ef3fc3a" - integrity sha512-JYzBiT4A8w+KQt+dOD5v+ti+tDrGoPnsSTuApq3Ls4RB5sfWbDlYMyz3dbc8qBIHz9tv0sQ5+eOu6Xwqzr5AQA== +"@react-native/dev-middleware@0.86.3": + version "0.86.3" + resolved "https://registry.yarnpkg.com/@react-native/dev-middleware/-/dev-middleware-0.86.3.tgz#6eeeabeb28cf4a49dd53888f6d307135d257e6b1" + integrity sha512-LiEPTqTg/63bYUnrPyHLfjTDCNhA/+CUqI1+DsA9tYyewtSbULd5awsva6SgE10I+2iMhgKXS3ymkhU/kSCrGA== dependencies: "@isaacs/ttlcache" "^1.4.1" - "@react-native/debugger-frontend" "0.85.3" - "@react-native/debugger-shell" "0.85.3" + "@react-native/debugger-frontend" "0.86.3" + "@react-native/debugger-shell" "0.86.3" chrome-launcher "^0.15.2" chromium-edge-launcher "^0.3.0" connect "^3.6.5" @@ -3652,41 +3616,41 @@ serve-static "^1.16.2" ws "^7.5.10" -"@react-native/gradle-plugin@0.85.3": - version "0.85.3" - resolved "https://registry.yarnpkg.com/@react-native/gradle-plugin/-/gradle-plugin-0.85.3.tgz#79f94aa38bc68be9b331cfd197de5bb53109465a" - integrity sha512-39dY2j50Q1pntejzwt3XL7vwXtrj8jcIfHq6E+gyu3jzYxZJVvMkMutQ39vSg6zinIQOX36oQDhidXUbCXzgoA== +"@react-native/gradle-plugin@0.86.3": + version "0.86.3" + resolved "https://registry.yarnpkg.com/@react-native/gradle-plugin/-/gradle-plugin-0.86.3.tgz#3a79ae4216800c5628e61798236ea44f98891a1b" + integrity sha512-lxmx0GqLEWRIpZfpYFXlYVIs3ENQwaW6Vmp6oi29l2GoQJ1wZfFZRdMimDWlGEk8LKfHar3QH3iaPMkTcK9lEQ== -"@react-native/jest-preset@~0.85.3": - version "0.85.3" - resolved "https://registry.yarnpkg.com/@react-native/jest-preset/-/jest-preset-0.85.3.tgz#e7ce02f0fdf2b51d06097598f24a3e4f234478bb" - integrity sha512-ALPSrM0q2fU+5AXcOXzDKx7rxVKPMvygAZfsTWLdrGRVWIqf/HEfM0R8euQqIKUqmEuQ1TxMWN+px3h6gc4vow== +"@react-native/jest-preset@~0.86.3": + version "0.86.3" + resolved "https://registry.yarnpkg.com/@react-native/jest-preset/-/jest-preset-0.86.3.tgz#164222da2070f7056a12c8e8f402fb06ef6f7f6b" + integrity sha512-oMmBwXUpTgJvGlcXQQPuiQLykoM8jsWYp6RwdJ2NWyIWzJcGp1rmSr3XV7istoIcUt0wdOVLtQs0A0nDm9ND+A== dependencies: "@jest/create-cache-key-function" "^29.7.0" - "@react-native/js-polyfills" "0.85.3" + "@react-native/js-polyfills" "0.86.3" babel-jest "^29.7.0" jest-environment-node "^29.7.0" regenerator-runtime "^0.13.2" -"@react-native/js-polyfills@0.85.3": - version "0.85.3" - resolved "https://registry.yarnpkg.com/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz#f3eef60ee619f51f78f8db7489abc192e685508f" - integrity sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A== +"@react-native/js-polyfills@0.86.3": + version "0.86.3" + resolved "https://registry.yarnpkg.com/@react-native/js-polyfills/-/js-polyfills-0.86.3.tgz#811f1d0bcb44325ed5969425420825a0fbfc3a27" + integrity sha512-eYIJ0es967+tePBFQDnl/gidVFxLns3fnbiK6rxscQrGodvuUO6hwxpQnfNynJ8MjbbndImXihXjcnJdc7SzJg== -"@react-native/normalize-colors@0.85.3": - version "0.85.3" - resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.85.3.tgz#681f4be6c0a57b5bcf16b993b3bc96e5a45bf3a2" - integrity sha512-hj0PScZEhIbcOvQV5yMKX3ha4XEIOy/SVE1Rrpp0beW0dpNLOgSC7KDxGewmDnIHK9YdQUXGY9eMEfShUMIaZw== +"@react-native/normalize-colors@0.86.3": + version "0.86.3" + resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.86.3.tgz#50176b5ff6d5533418c5a71c889b37e123d0b3cb" + integrity sha512-Cv3CDkprb67GrzuaS9BGbBJC/6G4lIw3nyKOHRKTqTTum4bn37y5+R0Z04L8mcbQN85eEohNrRwb7IOM4j6uvg== "@react-native/normalize-colors@^0.74.1": version "0.74.89" resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz#b8ac17d1bbccd3ef9a1f921665d04d42cff85976" integrity sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg== -"@react-native/virtualized-lists@0.85.3": - version "0.85.3" - resolved "https://registry.yarnpkg.com/@react-native/virtualized-lists/-/virtualized-lists-0.85.3.tgz#0fe478a10505d7fd3cfd39de26b6673a1fd0e091" - integrity sha512-dsCjI//OIPEUJMyNHp4l7zNLVjCx7bcaRUceOCkU+IB17hkbtbGWvi7HjGFSzy7FJGmS/MOlcfpb72xXiy1Oig== +"@react-native/virtualized-lists@0.86.3": + version "0.86.3" + resolved "https://registry.yarnpkg.com/@react-native/virtualized-lists/-/virtualized-lists-0.86.3.tgz#e4a6d9219b58441e54a71007f32cfaacf4a0c0c1" + integrity sha512-1j44NEyNn05Ut40vHAmoSWbsIcybFkMAOBTwQt1PrESyfSS+qBoyU1LGIogNva0VIa0rQyEC5PzbA7R4/7Nhyw== dependencies: invariant "^2.2.4" nullthrows "^1.1.1" @@ -4483,18 +4447,6 @@ lodash "^4.17.21" redent "^3.0.0" -"@testing-library/jest-dom@^6.9.1": - version "6.10.0" - resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.10.0.tgz#8a76841e94b72d55d09d2a34b9db9d75da9cbc08" - integrity sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ== - dependencies: - "@adobe/css-tools" "^4.4.0" - aria-query "^5.0.0" - css.escape "^1.5.1" - dom-accessibility-api "^0.6.3" - picocolors "^1.1.1" - redent "^3.0.0" - "@testing-library/react-native@12.9.0": version "12.9.0" resolved "https://registry.yarnpkg.com/@testing-library/react-native/-/react-native-12.9.0.tgz#9c727d9ffec91024be3288ed9376df3673154784" @@ -4504,11 +4456,6 @@ pretty-format "^29.7.0" redent "^3.0.0" -"@testing-library/user-event@^14.6.1": - version "14.6.1" - resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149" - integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== - "@tokenizer/token@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" @@ -5327,10 +5274,10 @@ agent-base@^7.1.0, agent-base@^7.1.2: resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== -agent-cli-detector@^0.1.2: - version "0.1.4" - resolved "https://registry.yarnpkg.com/agent-cli-detector/-/agent-cli-detector-0.1.4.tgz#13f74d20dd48007043afd436bef26594b05f6e9e" - integrity sha512-qPgevFvpaQoBaRJVKzr8R7h1WPvV3DtbgRIQlne4le66KBzXx5hNBwo/+NTw67LgkKBlhCzksrdautpUdlls0Q== +agent-cli-detector@0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/agent-cli-detector/-/agent-cli-detector-0.1.7.tgz#9d0b5d8cccaec3f207efa478890f189b773fae0b" + integrity sha512-d8OWDVdZMgjhLUT9ZPgSv/BdFFF9pVuscC0JdUSz3bjwE15gcp6u/o0/JooM2yyAWC49KThFhXlgTXRa9B7yng== aggregate-error@^3.0.0: version "3.1.0" @@ -5901,12 +5848,19 @@ babel-plugin-react-native-web@~0.21.0: resolved "https://registry.yarnpkg.com/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz#d2f7fd673278da82577aa583457edb55d9cccbe0" integrity sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA== -babel-plugin-syntax-hermes-parser@0.33.3, babel-plugin-syntax-hermes-parser@^0.33.3: - version "0.33.3" - resolved "https://registry.yarnpkg.com/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz#07602f8163cc7e63c4ec17d47467f2a3c5db70e9" - integrity sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA== +babel-plugin-syntax-hermes-parser@0.36.0: + version "0.36.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.0.tgz#51e429f32f23b197c477a20525798a7a97709c80" + integrity sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q== + dependencies: + hermes-parser "0.36.0" + +babel-plugin-syntax-hermes-parser@^0.36.0: + version "0.36.1" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.1.tgz#970277618fbec67b400bf8b01e4f61ba96ef5a40" + integrity sha512-ycduwJbvdvIMmVvlAZqGggS+pm5Eu4Bk9pcV9Sm2Z4PJNRVsKkv0g7vHj+LeuC1gHTeF67sJXFOq61IlqCa2hA== dependencies: - hermes-parser "0.33.3" + hermes-parser "0.36.1" babel-plugin-transform-flow-enums@^0.0.2: version "0.0.2" @@ -5944,10 +5898,10 @@ babel-preset-current-node-syntax@^1.0.0, babel-preset-current-node-syntax@^1.1.0 "@babel/plugin-syntax-private-property-in-object" "^7.14.5" "@babel/plugin-syntax-top-level-await" "^7.14.5" -babel-preset-expo@~56.0.19: - version "56.0.19" - resolved "https://registry.yarnpkg.com/babel-preset-expo/-/babel-preset-expo-56.0.19.tgz#f14909a69cfb4b097235d00970fc4b882d9a756a" - integrity sha512-js8jq/7x2jclTbfmc3Yp24B8r+KEAE7kmla2vpPVPiCfidi77lJMUAAZ3KBYXXOFaoWF0esAlFuLIuMIMHcmDg== +babel-preset-expo@~57.0.13: + version "57.0.13" + resolved "https://registry.yarnpkg.com/babel-preset-expo/-/babel-preset-expo-57.0.13.tgz#916f16b807e22bfdb72c179e7f0911a8176b0d3a" + integrity sha512-OOl3lqNQ4IagJfsNgtxuna0wpTWt1BswdyktGTOJ5EJCgdQUk/B2vaj/jsl03+kMyuIvpxKioVtbTzxh3mdvvQ== dependencies: "@babel/generator" "^7.20.5" "@babel/helper-module-imports" "^7.25.9" @@ -5985,10 +5939,10 @@ babel-preset-expo@~56.0.19: "@babel/plugin-transform-typescript" "^7.25.2" "@babel/plugin-transform-unicode-regex" "^7.24.7" "@babel/preset-typescript" "^7.23.0" - "@react-native/babel-plugin-codegen" "0.85.3" + "@react-native/babel-plugin-codegen" "0.86.3" babel-plugin-react-compiler "^1.0.0" babel-plugin-react-native-web "~0.21.0" - babel-plugin-syntax-hermes-parser "^0.33.3" + babel-plugin-syntax-hermes-parser "^0.36.0" babel-plugin-transform-flow-enums "^0.0.2" debug "^4.3.4" @@ -7301,7 +7255,7 @@ dmg-license@^1.0.11: smart-buffer "^4.0.2" verror "^1.10.0" -dnssd-advertise@^1.1.6: +dnssd-advertise@^1.1.4: version "1.1.6" resolved "https://registry.yarnpkg.com/dnssd-advertise/-/dnssd-advertise-1.1.6.tgz#47a543d25ec133dcca0a77468baaacc6987bbcc8" integrity sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg== @@ -8337,254 +8291,252 @@ expect@^29.0.0, expect@^29.7.0: jest-message-util "^29.7.0" jest-util "^29.7.0" -expo-application@~56.0.3: - version "56.0.3" - resolved "https://registry.yarnpkg.com/expo-application/-/expo-application-56.0.3.tgz#ec3f39fb0016fd5b7d93f6b2bfbe0581e7628292" - integrity sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw== - -expo-asset@~56.0.23: - version "56.0.23" - resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-56.0.23.tgz#aed372188ce147ea150c4b0c79eed46ac37207ab" - integrity sha512-QN0rEr7I6mAx4Hk2wWBdSzeTtt+6hqg2ImMBcL3SoCqHjxBUVJ05+wyb1jAlZOm/R0HkpMZbkGqBnyqUNSJB1w== - dependencies: - "@expo/image-utils" "^0.10.4" - expo-constants "~56.0.24" - -expo-audio@~56.0.13: - version "56.0.13" - resolved "https://registry.yarnpkg.com/expo-audio/-/expo-audio-56.0.13.tgz#c8979f35bc6c53575d46e55b3aa714836cda69f0" - integrity sha512-pfBmT/8OYbhrb37ECk+fC1EstLBFxMTioF4uZrAEeOj3uCgjproj1qYmkpsQLJIoEOMPSIw1iSfBEMl10v1DDA== - -expo-auth-session@~56.0.17: - version "56.0.17" - resolved "https://registry.yarnpkg.com/expo-auth-session/-/expo-auth-session-56.0.17.tgz#1305380f3e34ef039d67274fe923a9360e32fde4" - integrity sha512-jT3QT8i+E7hI7fYlTc6J+mZOSWSbawPKiAa9ceGvpJuwtCykSJDaY1/YBDWQ8Dm/OOYlCgo4Vk7SsAvGAxQK/A== - dependencies: - expo-application "~56.0.3" - expo-constants "~56.0.24" - expo-crypto "~56.0.4" - expo-linking "~56.0.17" - expo-web-browser "~56.0.6" +expo-application@~57.0.3: + version "57.0.3" + resolved "https://registry.yarnpkg.com/expo-application/-/expo-application-57.0.3.tgz#92d3f386e5a02b978c377a24e7eb32d1fcee1edb" + integrity sha512-t/CxeLio0NjY9/cOak1tQp62/hAUjzJxmhR4rVUR7K5bb9YoEtfl1ZekTn+Iuh+dYOMYzF0l72ODMSmY3CR1NA== + +expo-asset@~57.0.18: + version "57.0.18" + resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-57.0.18.tgz#c81177df21f2e84f88a1a5ca99292bd7ba425a55" + integrity sha512-zDIQkUr0NM2S7YRj98Zohf/OBuM1q0o4ctxLz1C1oe4/33lHNLjp+2v3qUtBWVwn0zKka8UGADmS8M+83Wi6pw== + dependencies: + "@expo/image-utils" "^0.11.5" + expo-constants "~57.0.19" + +expo-audio@~57.0.5: + version "57.0.5" + resolved "https://registry.yarnpkg.com/expo-audio/-/expo-audio-57.0.5.tgz#e749518742d50436b71d83f1bd2601c5db091f6a" + integrity sha512-39fX5JC5zDvELB4/eDJPj2XAYRN0W9+K2DfDWm5N3aC5WZ7HRrhupWtT6hbH09NlfYpsRSqYOwEZnocFM42ynQ== + +expo-auth-session@~57.0.13: + version "57.0.13" + resolved "https://registry.yarnpkg.com/expo-auth-session/-/expo-auth-session-57.0.13.tgz#f96aa1a4b8ccb7163968d8b816617a41cbbfb19c" + integrity sha512-Ky40Vn2ZQVTesh0pEALgcxPxKZdp7WHu2ODYh0lh1YD3ciQd6WhobYYj2oq8MO9Rx9BBsXC64d2L5+TG+mDgSw== + dependencies: + expo-application "~57.0.3" + expo-constants "~57.0.19" + expo-crypto "~57.0.3" + expo-linking "~57.0.11" + expo-web-browser "~57.0.3" invariant "^2.2.4" -expo-build-properties@~56.0.26: - version "56.0.26" - resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-56.0.26.tgz#29061aa33655196461a32d9aa7779bb643d43179" - integrity sha512-0sNVshDBa4nmR+8aNiQ/57oJrlpi7yDrdlm8PMZu3g6u2xTncmVXjh2kY7n3tVbfnKPRPXmut4OgQ4/8oaZTkA== +expo-build-properties@~57.0.22: + version "57.0.22" + resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-57.0.22.tgz#38f2d91e0db8d43d4c6471df277cc9fcdf6ecd75" + integrity sha512-A5oRf2I02XDRu2NnqKo7Au5kUu244unvyoRMUsB+xvg/BOlsR+G/oyDFGkjbda1cJbeKm9eqposIXbA2ri5UrQ== dependencies: - "@expo/schema-utils" "^56.0.2" + "@expo/schema-utils" "^57.0.2" resolve-from "^5.0.0" semver "^7.6.0" -expo-clipboard@~56.0.4: - version "56.0.4" - resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-56.0.4.tgz#b15ea42f4d0b149c76a34a19c899136e2049068e" - integrity sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA== +expo-clipboard@~57.0.2: + version "57.0.2" + resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-57.0.2.tgz#639996f99407acd34d37ba3d3b7f0b094ca62d97" + integrity sha512-VB4Au8X/RbvJKUtJk+87vdtveWSNEa5RzY3ooeO6VNF7Rd49RbQXZOu3/TfEW9p7mU8TCfyTvjyCiAUwSoSJ4w== -expo-constants@~56.0.24: - version "56.0.24" - resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-56.0.24.tgz#c54c71a0b0588c08d776c80398a6ca35ac22ebfd" - integrity sha512-DxlaB0pb0Cy0QEnd7WnKVy4L5nCRQY58ePCWCTvbIVbZZ4zTt1Y1xFHeAjT+2p4PGXJ87bjWskscajyXUopP2g== +expo-constants@~57.0.19: + version "57.0.19" + resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-57.0.19.tgz#9df45e23672342f44f12f290399d95097af738c6" + integrity sha512-3eK1kZRwCdMizQLlM/PPlPwjaPnB5qKvdRDMBMnB1ZxmvWJBf9TB4uEO4DMMfIPaFUxYFs29dsR5fPr1gh3FTQ== dependencies: - "@expo/env" "~2.3.1" + "@expo/env" "~2.4.3" -expo-crypto@~56.0.4: - version "56.0.4" - resolved "https://registry.yarnpkg.com/expo-crypto/-/expo-crypto-56.0.4.tgz#37e9be1f0901185fa83178d802f49695fc71422e" - integrity sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g== +expo-crypto@~57.0.3: + version "57.0.3" + resolved "https://registry.yarnpkg.com/expo-crypto/-/expo-crypto-57.0.3.tgz#e232bf2775f268baa8c5afab30085dc850410bb2" + integrity sha512-SAWqEfF37nc8ot7bXsVmu9AFYwQdC2kGvH/eNAHiR3bmlcf1Vv8wu9AnGKT+a90r3T8YfwMpZP4QAYlo4vCn9w== -expo-dev-client@~56.0.25: - version "56.0.25" - resolved "https://registry.yarnpkg.com/expo-dev-client/-/expo-dev-client-56.0.25.tgz#ebe2f215c0282be3aa9c91e497f6566d7d38f0c0" - integrity sha512-rV2SzS3TjUU0gz2l6X97DhazUvgkpIy/RoB7bP1+ieq3ru90IPXGcLQ6O/9iNYG8tWs101jNC8U9ZHL+SLOgVw== +expo-dev-client@~57.0.19: + version "57.0.19" + resolved "https://registry.yarnpkg.com/expo-dev-client/-/expo-dev-client-57.0.19.tgz#aefa665fcb736365ac161d4811723c8b4498b33e" + integrity sha512-7giMK6BLL685GECHem2UAQvS473ImZLi5l+Q5tq+XSyv6I9UNVPs0p07mKwgBzq8z7DR3d4nTl702ziFxTjO3Q== dependencies: - expo-dev-launcher "~56.0.26" - expo-dev-menu "~56.0.22" - expo-dev-menu-interface "~56.0.0" - expo-manifests "~56.0.4" - expo-updates-interface "~56.0.1" + expo-dev-launcher "~57.0.20" + expo-dev-menu "~57.0.18" + expo-dev-menu-interface "~57.0.0" + expo-manifests "~57.0.2" + expo-updates-interface "~57.0.2" -expo-dev-launcher@~56.0.26: - version "56.0.26" - resolved "https://registry.yarnpkg.com/expo-dev-launcher/-/expo-dev-launcher-56.0.26.tgz#de65aeef4eaa912cf92acf8c4b037cc35cbe7db9" - integrity sha512-p8qM4lutkTWLFCJd/GrVXrF3au4kxUPXirHZyRqY7k/COHLw8MgsQeqcHVqGYcE/YVzpNLexq19hHumPoAO4Pw== +expo-dev-launcher@~57.0.20: + version "57.0.20" + resolved "https://registry.yarnpkg.com/expo-dev-launcher/-/expo-dev-launcher-57.0.20.tgz#1a268a13294223c9577d4d9fffba85792018773c" + integrity sha512-F6oAHaDn9j3L/gyMGE+OalriX6PHZWnZLxpN5jc6crTrp8f6hSwGhmsHpAvdOzijfMjyi3R2UkcISFsCIIQYFA== dependencies: - "@expo/schema-utils" "^56.0.2" - expo-dev-menu "~56.0.22" - expo-manifests "~56.0.4" + "@expo/schema-utils" "^57.0.2" + expo-dev-menu "~57.0.18" + expo-manifests "~57.0.2" -expo-dev-menu-interface@~56.0.0: - version "56.0.1" - resolved "https://registry.yarnpkg.com/expo-dev-menu-interface/-/expo-dev-menu-interface-56.0.1.tgz#2e072832775c5a5fba8fd1630095c5f905f10583" - integrity sha512-odATx0ZL/Kis10sKSBiKiGQxAB6coSi/KQtKcMhnQVNno6FkRh5/4e5BqcEvpq2rNMTiQp4ytNAQHtdwbPXvGA== +expo-dev-menu-interface@~57.0.0: + version "57.0.0" + resolved "https://registry.yarnpkg.com/expo-dev-menu-interface/-/expo-dev-menu-interface-57.0.0.tgz#23bd53c35d00cead3fd963a9bc3e2abf8c00467d" + integrity sha512-F47VdzOHYc19FhI/jBgctpO8a5UskTIxG6a1E5t3W5gF8VImuvBQffdXXfLHhsuCl7dS3v3U0R45cleeVXO1Zg== -expo-dev-menu@~56.0.22: - version "56.0.22" - resolved "https://registry.yarnpkg.com/expo-dev-menu/-/expo-dev-menu-56.0.22.tgz#b5a153c68c45b34461381774cd28316d03afdf47" - integrity sha512-xTo3WxcFll3YVnvKXo4KIOGeUwSABrnxztv1J+JzbEc77pOVQhc+ZepXz4c8fksCDBMIW0kO00O29Oe8HotfhA== +expo-dev-menu@~57.0.18: + version "57.0.18" + resolved "https://registry.yarnpkg.com/expo-dev-menu/-/expo-dev-menu-57.0.18.tgz#5256bf45ed213a5387a02c35b4028e14d2583486" + integrity sha512-H7m9ENtVYz4FpJktQj+z/njIN1t7kOEaKCv8Qgv2SdAlyFTW8s2EnufxPoSlg2zUTanaauJ6wrR6XESHwW7kpw== dependencies: - expo-dev-menu-interface "~56.0.0" + expo-dev-menu-interface "~57.0.0" -expo-device@~56.0.4: - version "56.0.4" - resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-56.0.4.tgz#11b9d9f2dd65ec95d60a2232e137b5ce104ef87f" - integrity sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg== +expo-device@~57.0.2: + version "57.0.2" + resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-57.0.2.tgz#e6f015372e508d78c7d1ffb1e0a9f0bd366521ac" + integrity sha512-1A0/FICR3CgrH43FeiDQP3l5eHDmiMoh4EjXfhfoHHkMgdBQdyD0kwDWskiGZlQ3E0Nd0LHkCFH6vJ1ofm/jow== dependencies: ua-parser-js "^0.7.33" -expo-document-picker@~56.0.4: - version "56.0.4" - resolved "https://registry.yarnpkg.com/expo-document-picker/-/expo-document-picker-56.0.4.tgz#ee80925f6dc212622fd6fd9846784774ea4b1d15" - integrity sha512-75Apf74XNkYYohObIH19VZw42xpe0gmEnPccuzGXKVAzlvTYCfibSgW17F+6vt4paOfZEnAoZ1QFZM6dmaujRA== +expo-document-picker@~57.0.2: + version "57.0.2" + resolved "https://registry.yarnpkg.com/expo-document-picker/-/expo-document-picker-57.0.2.tgz#d972b544ad121ef07c4cc6ee62d8b1e95c110d86" + integrity sha512-vVBbBNg0piMJUIxjB+DR6JaZZpcSl90ZUoywLA/EgNHTfRkZW+9axP97UxM2a0Zm0W6WfH3cLzzGAEfP9OKGwg== -expo-file-system@~56.0.10: - version "56.0.10" - resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-56.0.10.tgz#7a99d91e1496f0ce7676d142bcea1a1de3c9309d" - integrity sha512-qXX6lGnt0nIouZJMaaxqEMiWvDRTk+1aQxCzowZiZ22xD5VQIa25glX1/d+YpUhuB8Rh5auSdgFUbH+xliL+rg== +expo-file-system@~57.0.7: + version "57.0.7" + resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-57.0.7.tgz#03c4587c0027f65f7e04d88796aefcfe74b03cae" + integrity sha512-1vkZRadOsIputFDBcclqBa1pO3iXOO6iIsdqJipQWw4V5TSnWgmRTuOF7ro8IzrVue+PHbum++KCG3Tawem4cg== -expo-font@~56.0.7: - version "56.0.7" - resolved "https://registry.yarnpkg.com/expo-font/-/expo-font-56.0.7.tgz#429569724a3e811c289ef9d922e2115d8c5802bf" - integrity sha512-hpU/vRwPzsby9lPGkA4blDqLIIXYzoWnCZHr6PxvcWbY/uPObAiyhh6q+e0WYsB65SthK+PLH95jEnVag7fwEg== +expo-font@~57.0.4: + version "57.0.4" + resolved "https://registry.yarnpkg.com/expo-font/-/expo-font-57.0.4.tgz#ccd67dd2ee3168510a1a2453a5a8b8fd7f8a5fe4" + integrity sha512-7WOMC2oA6xCfOm8bbofqWn2bIqyWFm7YLEnpc/gA1MJH0Ll0TfT/3+CqRkoW2eNRWMS8sxrz5WukUmSvmNquHA== dependencies: fontfaceobserver "^2.1.0" -expo-glass-effect@^56.0.4: - version "56.0.4" - resolved "https://registry.yarnpkg.com/expo-glass-effect/-/expo-glass-effect-56.0.4.tgz#4549a8c84058fb88619ecb2e818be36f2fe003ee" - integrity sha512-xI9rXtDwi7RW82uAlfyaXO6+k21ApWJ2tHAWYqPr/FjfmZbKsgNJ4Q0iZzGPCwboqjTGxaRZ61SZxBl8hDt5iA== +expo-glass-effect@^57.0.4: + version "57.0.4" + resolved "https://registry.yarnpkg.com/expo-glass-effect/-/expo-glass-effect-57.0.4.tgz#9b7e65b1475970f0662c045b71fce99bf2710a5c" + integrity sha512-/AG2Kljbqfgxu+GchI2YFIbxiF9cPsIpCy4iNDzlRiZk9rvR6BwRfB6gfQVu3V2ExaoGf2h5IsEnqVI5MYNBAg== -expo-image-loader@~56.0.3: - version "56.0.3" - resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-56.0.3.tgz#665112665e78d0fd9629f6160e2ca419d7f0c5c6" - integrity sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA== +expo-image-loader@~57.0.1: + version "57.0.1" + resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-57.0.1.tgz#b031dc3e62ac09004f3e436d7081487236791f53" + integrity sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug== -expo-image-manipulator@~56.0.25: - version "56.0.25" - resolved "https://registry.yarnpkg.com/expo-image-manipulator/-/expo-image-manipulator-56.0.25.tgz#d21a9240a2f83d33ae2c1dcc5ab1f5ab8292b825" - integrity sha512-12eo/bRfTSYfU+IaZ2gzbcXJX/vFUOszBTZ4dkde8pf0OvcMHBVEGGBY+BPgEDCCNcml/knFSvOwIRHFTHplTw== +expo-image-manipulator@~57.0.20: + version "57.0.20" + resolved "https://registry.yarnpkg.com/expo-image-manipulator/-/expo-image-manipulator-57.0.20.tgz#88648a2391e114906358a28a90ed7fd5d8b36398" + integrity sha512-LyuoLOJVNWNVEOABqcNCHrAiTtaRQm3usJMK4veG1hnglmt85eSuQTgUaug2TdUyAwGDJbdee4Iif+toTQwNgA== dependencies: - expo-image-loader "~56.0.3" + expo-image-loader "~57.0.1" -expo-image-picker@~56.0.24: - version "56.0.24" - resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-56.0.24.tgz#4e96a5059a020ce7ba15161598e13927ddfd4e4f" - integrity sha512-5GuHVqHUhf9RM96MDZwHdsSh9F/H7DmXKnDbXlONNK8+AwTsSDwB1NSwLZgl8CxlPbZXYaGcELVJdLLscXvZng== +expo-image-picker@~57.0.20: + version "57.0.20" + resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-57.0.20.tgz#c32efa3daad2880ebf54e9dc0cddd09e57967a34" + integrity sha512-plgcGuMZMWqDchjXQW6S/WepYLB5KqfbZOzwC6xe0+j5IhY4ipvVuRZcXeP/0NgKgU0Jiu/XoTK4Bi8crtZ+sQ== dependencies: - expo-image-loader "~56.0.3" + expo-image-loader "~57.0.1" -expo-image@~56.0.12: - version "56.0.12" - resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-56.0.12.tgz#d0d014b46a123ba506883f114fcdf60cfeace39d" - integrity sha512-R1LDk/D/VazqcmT1OIEBV7+BDezyyJbi594ZstlklCR6aMUBNBcjQNxoEa8YaIxNdT3BsRS46/VDHpmHg4B23Q== +expo-image@~57.0.5: + version "57.0.5" + resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-57.0.5.tgz#fafabb49e50fafc3795bb1d4d221cc5b6a1780b1" + integrity sha512-jOm5lofaIKXhQ7i0Hz3I5JuNULw/aghmpIPZXddwQcEtw8+wUhlozq3M7LbsOrcsBEapcG4jFs85DsfuFoz1tQ== dependencies: sf-symbols-typescript "^2.2.0" -expo-json-utils@~56.0.0: - version "56.0.0" - resolved "https://registry.yarnpkg.com/expo-json-utils/-/expo-json-utils-56.0.0.tgz#ddf903e571c3d985ca670c028d06970926a1d3e0" - integrity sha512-lUqyv9aIGDbYTQ5Nux2FnH2/Dz0w5uJ8Pr080eS0StXi2jr5OmuMNErpzUnpfnYOU55xKotd4AHv68PfV/ludg== +expo-json-utils@~57.0.2: + version "57.0.2" + resolved "https://registry.yarnpkg.com/expo-json-utils/-/expo-json-utils-57.0.2.tgz#913c4389e1b51a4429e13e09ea1923541ca79ef3" + integrity sha512-9kYZECLKE3pYZQs+kt3+ggY83Lfryog9/UEo3HJKGcHUZeKKtlFBIPFjI4LzREubsvjP7AsvG0FdEX9EquIoAQ== -expo-keep-awake@~56.0.3: - version "56.0.3" - resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-56.0.3.tgz#88991b4859c77af73cf68c500839a18fe5d8febe" - integrity sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA== +expo-keep-awake@~57.0.2: + version "57.0.2" + resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-57.0.2.tgz#ce266252ad478523bb3431d40f67bd643f86bd9c" + integrity sha512-GqgH746wtJImmsbyHmCQoi7cOslKuWQBbJHXr53S35Bpf5UvxCAztvnitUEd7D0QvFN2rvkOhylbZpgZZfBLlQ== -expo-linking@~56.0.17: - version "56.0.17" - resolved "https://registry.yarnpkg.com/expo-linking/-/expo-linking-56.0.17.tgz#610e011e11b0fea78786b06be4ef7cb2baa0912a" - integrity sha512-+i2VaJlD2eamBIBetpOAkfFGYPEn01kZfRF73bbjlNiI8AkTqWWClZuJqxqHy3/R8dCCbwTTq/dJqI5dskuDvQ== +expo-linking@~57.0.11: + version "57.0.11" + resolved "https://registry.yarnpkg.com/expo-linking/-/expo-linking-57.0.11.tgz#49bd06c942afd4094f73fac523c249702ea46ecb" + integrity sha512-bfTyiPb8szacs8XkGowXt5ssH/vbF26vFG8WUWQar8CcRpQcIzXY980ZkP7pK7Pcdy5MattwFdujrWOxwv5s3Q== dependencies: - expo-constants "~56.0.24" + expo-constants "~57.0.19" invariant "^2.2.4" -expo-localization@~56.0.6: - version "56.0.6" - resolved "https://registry.yarnpkg.com/expo-localization/-/expo-localization-56.0.6.tgz#386663c5cb137d763e055feabeb74ec2b43b2194" - integrity sha512-zzBVoUFHCVNBywcxGsspoZeIXebihOo/AnmQYE4jMv8gHCSKlLNFT+ft+0+mWcZCMs9necvUs8S8TDonAu/xBA== +expo-localization@~57.0.2: + version "57.0.2" + resolved "https://registry.yarnpkg.com/expo-localization/-/expo-localization-57.0.2.tgz#13caf8073eb37ff1f689b345bc131c3d49b246fd" + integrity sha512-sIKYH3Ivddoge568WY6j+Kp2MAAZzWWqEnY2bokLkJFwX366tGL1mEnoY2WUi/qHatFFPop2yqDdurzbhnnimQ== dependencies: rtl-detect "^1.0.2" -expo-location@~56.0.24: - version "56.0.24" - resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-56.0.24.tgz#1154dd41ca671ea65dbd50bf999fc8c7ef2fb8cf" - integrity sha512-ZSrqs1yoMfyovJdWQPluAxHp27FUhsGxA/PiduVk9Uz1w3ScLFJpRmOU0yUkGRCAfAnAzkaxWcemEBmOGqexmA== +expo-location@~57.0.20: + version "57.0.20" + resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-57.0.20.tgz#1862a3440d1fe785edb3f5681d8cc01761041d54" + integrity sha512-E9cr7A5eQS91bm3McMx3FiWy7SCorVTPLq9gLFFW+7dfuSqQp5FLNBZQ6NFJaS8tVhCyl2WyKMFh0TAo/+7K9Q== dependencies: - "@expo/image-utils" "^0.10.4" + "@expo/image-utils" "^0.11.5" -expo-manifests@~56.0.4: - version "56.0.4" - resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-56.0.4.tgz#ae3ac93868cb0fb3940ab43b166a98f82e24a00f" - integrity sha512-Fokawl2UkiExIF0bqGoblRFA8lYpROVD+EpvDwSW4LgqQyPwNua1gLSgHZjdl5GsVugfRMMWE3LHaibDyX93hw== +expo-manifests@~57.0.2: + version "57.0.2" + resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-57.0.2.tgz#c8af35f2e0f3b45cef826ac5a39d02b0a7ed54ff" + integrity sha512-pPIIcKR73OShwlWMg8H90WRzKUd2krDdKlKAwuQV1FtUwE7H3hQEFw0ePFQY9yFoGMF/77ryYwNKfI5umMcCog== dependencies: - expo-json-utils "~56.0.0" + expo-json-utils "~57.0.2" -expo-modules-autolinking@~56.0.22: - version "56.0.22" - resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-56.0.22.tgz#7eec0c3bd4de3ec817856c4521708641c8b4e16e" - integrity sha512-okvttTwsaDHzyRLp4/1qpkK2QV9gFIhp7qWZlIHzM4xCatoPSTT2dtvBD5VbXkf4whWUA7Y+DJy99hxnnUONYg== +expo-modules-autolinking@~57.0.13: + version "57.0.13" + resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-57.0.13.tgz#64c93c919ff6207d5b2183fcc4e954a440baf8d2" + integrity sha512-Hj5NjZRlfccazhKab+wK19leRVUr1Y/PUERaRvegIbUSkq4GVxKq6lU9QHlKQyC16MdD/VS4iwsiLivB4o7lvQ== dependencies: - "@expo/require-utils" "^56.1.6" + "@expo/require-utils" "^57.0.5" "@expo/spawn-async" "^1.8.0" chalk "^4.1.0" commander "^7.2.0" -expo-modules-core@~56.0.24: - version "56.0.24" - resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-56.0.24.tgz#43d85521c208776425e0957a5b8400ce6ca2be37" - integrity sha512-cwBCdPS0lRM2jEmSUhF9vV//HnjJ/Ysb8EwkwxQW7ltSU/YvPNulfRhF4UsdXxw/mUfHvrH7AF+i6oxu3pHtMw== +expo-modules-core@~57.0.19: + version "57.0.19" + resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-57.0.19.tgz#2a9021c7d5658c4574c70607b06312298936dd46" + integrity sha512-eAJwa5u30r4BTT8cFAcrY3809qG1cPszmEci7//+RjWGA1OMXtwirhVY1H+YtCJPyVGs9MuwrIjqQDMReguG2g== dependencies: - "@expo/expo-modules-macros-plugin" "0.2.2" - expo-modules-jsi "~56.0.12" + "@expo/expo-modules-macros-plugin" "0.6.1" + expo-modules-jsi "~57.1.1" invariant "^2.2.4" -expo-modules-jsi@~56.0.12: - version "56.0.12" - resolved "https://registry.yarnpkg.com/expo-modules-jsi/-/expo-modules-jsi-56.0.12.tgz#9abcdb2f94e6c08171899dec0bfd5923639bfc57" - integrity sha512-OnXiNbXzYybZPh5QnJyIIwQjQfN7PP4Di/2Hz0xQHKLgQmCfn/zAQziOjbUXVeL3cAyZ8+uDnTDYmDAc3B2qhQ== +expo-modules-jsi@~57.1.1: + version "57.1.1" + resolved "https://registry.yarnpkg.com/expo-modules-jsi/-/expo-modules-jsi-57.1.1.tgz#0821e42a6a7b1b962cb14416889107481182f62b" + integrity sha512-ssnwz3ZuIgCbZj5A3J6NlhQrXZojg+BjkeWrOWqVJhyMHlV/A/cRT7s986chKku97BFt6ucmDu6/BCzrV45Wcw== -expo-navigation-bar@~56.0.3: - version "56.0.3" - resolved "https://registry.yarnpkg.com/expo-navigation-bar/-/expo-navigation-bar-56.0.3.tgz#ef0b91e8db6d5bf64d814936e21e5a5daf137f0d" - integrity sha512-7k8jyJojMs59gIsAPOFk/gCz1BTFy5S0Qf3i2uUW1f2CFoWcQSSyR4VnsmppBkzIKrBzZjcuFmdsZmYO+GZ4og== +expo-navigation-bar@~57.0.2: + version "57.0.2" + resolved "https://registry.yarnpkg.com/expo-navigation-bar/-/expo-navigation-bar-57.0.2.tgz#d22ede0b1c9ddb9291417994dbefbd6f21bcf423" + integrity sha512-Ufe49dsTZjarA7gMMwwwnNJWpXo+oTyl5Ud0g3lsGVrj2HXjyw9cb9098N8+CoWpjfIa5PghuRYYXrcCrGQIHg== dependencies: debug "^4.3.2" -expo-notifications@~56.0.24: - version "56.0.24" - resolved "https://registry.yarnpkg.com/expo-notifications/-/expo-notifications-56.0.24.tgz#a994ab92a3babd29f7f400869f70ca3a40229059" - integrity sha512-AslIt5BCrl1x2/nhpIw/VOVquGeZ7h5tdYT/ykZ39qYKyJaJ+SKPKQEWuXDX4DDZd8qbDdNzFGKGIcUfFosDTg== +expo-notifications@~57.0.21: + version "57.0.21" + resolved "https://registry.yarnpkg.com/expo-notifications/-/expo-notifications-57.0.21.tgz#228d9b733063c020559a643935cc16b74ea65aeb" + integrity sha512-CWBW6DxqRJ2MaYZW5+/WfEWwIwbiVk5UAPqx0kE676B4R1+PlAXARk/kuNeQBcVrcDNV6i9ihE9B0db8Cd2RXQ== dependencies: - "@expo/image-utils" "^0.10.4" + "@expo/image-utils" "^0.11.5" abort-controller "^3.0.0" badgin "^1.1.5" - expo-application "~56.0.3" - expo-constants "~56.0.24" - -expo-router@~56.2.19: - version "56.2.19" - resolved "https://registry.yarnpkg.com/expo-router/-/expo-router-56.2.19.tgz#9e91360c01644f6c7dcfaee3f466f079b9458651" - integrity sha512-SnhQjmQv3pauoVYUaCVI9AikKNiO7FXBgrQ0oVw/fn2ivP79GKElP7opEOlNOBE9gsGrHrsA3Co3xFXIf19ujA== - dependencies: - "@expo/log-box" "^56.0.14" - "@expo/metro-runtime" "^56.0.20" - "@expo/schema-utils" "^56.0.2" - "@expo/ui" "^56.0.26" + expo-application "~57.0.3" + expo-constants "~57.0.19" + +expo-router@~57.0.23: + version "57.0.23" + resolved "https://registry.yarnpkg.com/expo-router/-/expo-router-57.0.23.tgz#d15a2dd4a983028a653ed227edb237007ba1b4a6" + integrity sha512-u/iWSdQovEma/owymgz8etxGAif5ZrC8YdXYeGQhDRZwXpAvraordYV0SWej1QWWbYoIkgXcoqHVkgsAff8P4w== + dependencies: + "@expo/log-box" "^57.0.4" + "@expo/metro-runtime" "^57.0.16" + "@expo/schema-utils" "^57.0.2" + "@expo/ui" "^57.0.20" "@radix-ui/react-slot" "^1.2.0" "@radix-ui/react-tabs" "^1.1.12" "@react-native-masked-view/masked-view" "^0.3.2" - "@testing-library/jest-dom" "^6.9.1" - "@testing-library/user-event" "^14.6.1" client-only "^0.0.1" color "^4.2.3" debug "^4.3.4" escape-string-regexp "^4.0.0" - expo-glass-effect "^56.0.4" - expo-server "^56.0.6" - expo-symbols "^56.0.7" + expo-glass-effect "^57.0.4" + expo-server "^57.0.3" + expo-symbols "^57.0.3" fast-deep-equal "^3.1.3" invariant "^2.2.4" - nanoid "^3.3.8" + nanoid "^3.3.18" query-string "^7.1.3" react-fast-compare "^3.2.2" react-is "^19.1.0" @@ -8596,107 +8548,107 @@ expo-router@~56.2.19: standard-navigation "^0.0.5" vaul "^1.1.2" -expo-screen-orientation@~56.0.5: - version "56.0.5" - resolved "https://registry.yarnpkg.com/expo-screen-orientation/-/expo-screen-orientation-56.0.5.tgz#c6215ec9ba238c02606feb5fc6162b5b4a2f1170" - integrity sha512-Puf4L/cgM8z45Z2fwZzJtlVGSk0ZM/l3gBqXm50bKTACmUk8P8fr7HVbDfs8reyoZuEKKFZJ0VlnKo5i6cSotQ== - -expo-secure-store@~56.0.4: - version "56.0.4" - resolved "https://registry.yarnpkg.com/expo-secure-store/-/expo-secure-store-56.0.4.tgz#5f897b4446c208e4670a71c7d8b1cf1467161dd2" - integrity sha512-hjEi/gmpdFFJ9lYbdp3k3p/WchV7Gi0Qt8jt/m/0WJadqQrskafHAlDxbZkII1cN3Yd7zp9Lvkeq3UfGhSwirQ== - -expo-server@^56.0.6: - version "56.0.6" - resolved "https://registry.yarnpkg.com/expo-server/-/expo-server-56.0.6.tgz#189db144efa1842836b4c8ec5e0718c0ff6a150b" - integrity sha512-3749PixW3Fs42lFSvi8wYBbMNexb/lVtVyN50BhT0hzX37bT9HrJD4siokV5InThAkpFwdfxGQdWNuAECg9ZCg== - -expo-sharing@~56.0.25: - version "56.0.25" - resolved "https://registry.yarnpkg.com/expo-sharing/-/expo-sharing-56.0.25.tgz#e31f3e3b46b75b8bdb88498551025222f3c7a85c" - integrity sha512-X3QviZbMuWrizYmsGojmbahoKBappNe8AvGl+g+Heh1Ebgr3thHotJbBOkgTK5scBszA3qc83MZTtHJ3gAtRaQ== - dependencies: - "@expo/config-plugins" "^56.0.15" - "@expo/config-types" "^56.0.7" - "@expo/plist" "^0.7.0" - -expo-splash-screen@~56.0.14: - version "56.0.14" - resolved "https://registry.yarnpkg.com/expo-splash-screen/-/expo-splash-screen-56.0.14.tgz#2f4fb5336508f68f1fc102c89c110e8689580c9e" - integrity sha512-7cc09zKllBrmDGpbkVDiq09JBdpzyZMboOYNOd7Q+h4YbCbvkAW4WmZtxXLpZUEeBrc2uakxWyrz5nwcuHqMoQ== - dependencies: - "@expo/config-plugins" "~56.0.14" - "@expo/image-utils" "^0.10.4" +expo-screen-orientation@~57.0.2: + version "57.0.2" + resolved "https://registry.yarnpkg.com/expo-screen-orientation/-/expo-screen-orientation-57.0.2.tgz#6c576475cdc5356a7345d270db023cbafb805407" + integrity sha512-9wIa/dU561fidHYChkg6LIDAcYr8uMBa8fOws1Km9/slaF3N/a2kgcy0TUZWvNIe7at3r4g2kVva9vRBJBkaEQ== + +expo-secure-store@~57.0.4: + version "57.0.4" + resolved "https://registry.yarnpkg.com/expo-secure-store/-/expo-secure-store-57.0.4.tgz#a5eefc7490d1de26fd62e83bc5fdceacd94f5342" + integrity sha512-7zBA1RThURTXdP4w65kBzZNbcSsfArqVd+p+ETAnPRWXO8J296Qd0YaCVyRpJ0HSwlCnGdvOnqO+NgSreHsOhA== + +expo-server@^57.0.3: + version "57.0.3" + resolved "https://registry.yarnpkg.com/expo-server/-/expo-server-57.0.3.tgz#02e038a85453f1f4eb00024256b4b3cab949cace" + integrity sha512-aK+LdKzauHSGmsOStZtyxdzv0zWssCkxTw3m4QuOhfDSJsZaMRTd9O41d8ixU/QfELTbaJ0oRNcF7JFV/7O9YQ== + +expo-sharing@~57.0.22: + version "57.0.22" + resolved "https://registry.yarnpkg.com/expo-sharing/-/expo-sharing-57.0.22.tgz#03e630b34986374023693356b7a85bec8dcfdd21" + integrity sha512-IWmiz0Dnjd7ifsZ2Gg5XODxb6DQoJ4/H6FofW9bTL9gDjfX2+va8SV1BYVkFvJ8vKQoKR4HgkMCKDCZlrrbKkg== + dependencies: + "@expo/config-plugins" "^57.0.9" + "@expo/config-types" "^57.0.2" + "@expo/plist" "^0.8.1" + +expo-splash-screen@~57.0.9: + version "57.0.9" + resolved "https://registry.yarnpkg.com/expo-splash-screen/-/expo-splash-screen-57.0.9.tgz#916e23cdfcb1538ba88e091692dedcb36b13b08d" + integrity sha512-7cKDn7uBwg0RkcmY015/5j9st4msWJ4zQiPmQdVZoppW3cFSbv05n7xhM6CjUU466Tx4VRTqDF21HuIHkmnEtA== + dependencies: + "@expo/config-plugins" "~57.0.9" + "@expo/image-utils" "^0.11.5" xml2js "0.6.0" -expo-status-bar@~56.0.4: - version "56.0.4" - resolved "https://registry.yarnpkg.com/expo-status-bar/-/expo-status-bar-56.0.4.tgz#01a0b6f9946ae51fe9d85c961ae9b485383cfcab" - integrity sha512-IGs/fDfkHXofy2ZQrGiXayhFK04HB85FZXorhcEhDZEcqASKgSqpak+HwUtAaR0MeTJwWyHNF7I6VmVbbp8EcA== +expo-status-bar@~57.0.1: + version "57.0.1" + resolved "https://registry.yarnpkg.com/expo-status-bar/-/expo-status-bar-57.0.1.tgz#0a8ec1657fcd0987f9612ca411a0c3d002f0eaf0" + integrity sha512-Xwaq1gAoVRWx5dPG5VhT5RSbnI9OilhZnO5qoPBnUaBAa5VzRzfdS8q0/bsPt0jR2DKLtGuP0bQ6efMJ4RIMDg== -expo-symbols@^56.0.7: - version "56.0.7" - resolved "https://registry.yarnpkg.com/expo-symbols/-/expo-symbols-56.0.7.tgz#c68196cc0bc802332b8389bedc2d1208d5a95559" - integrity sha512-OddQ8qWSHaTN8UvyVkcwZSGw0NEH/XRXsofA8pjX4aThyKH60Q3U0tcoberb0cCBCTbfd8SuyjYMbtwnQeYh3g== +expo-symbols@^57.0.3: + version "57.0.3" + resolved "https://registry.yarnpkg.com/expo-symbols/-/expo-symbols-57.0.3.tgz#291f56d981d8271842aa0878b7e197c1793c1eef" + integrity sha512-uuK5yHLVR7kExUCrO/ItBvp/kp1maQ9Nkb6KvNy5ZUQnN0NMKwuhJ4GvbfS9N3ACmvCjTT4gKvg4yH5UuKIYNA== dependencies: "@expo-google-fonts/material-symbols" "^0.4.1" sf-symbols-typescript "^2.0.0" -expo-system-ui@~56.0.5: - version "56.0.5" - resolved "https://registry.yarnpkg.com/expo-system-ui/-/expo-system-ui-56.0.5.tgz#c23c3dc772c41df356dfa620071e5b4f479c7de2" - integrity sha512-n1MmnUArV4cc3gVed9fGtluPme00PE9axKVx+NHbKxHFMam5l4GcOI7PxbYKFNx8o7WA1LRD7eLW33agmZrxGg== +expo-system-ui@~57.0.4: + version "57.0.4" + resolved "https://registry.yarnpkg.com/expo-system-ui/-/expo-system-ui-57.0.4.tgz#e70154f9b9e2e2d28d94b6bfa05992e200ba6b6d" + integrity sha512-z//rxQJzhTNA1PcKrUtWmrd21jAS3nEDu1b0n8B/7LTTR933OcsZYP1HnbaZifuo2ZH/h7nce2Ow61WZ1Ig2bg== dependencies: - "@react-native/normalize-colors" "0.85.3" + "@react-native/normalize-colors" "0.86.3" debug "^4.3.2" -expo-task-manager@~56.0.26: - version "56.0.26" - resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-56.0.26.tgz#b94fe3b91ac39dede8f4e466cb8803e0138c4b1d" - integrity sha512-MKum9NzLthKmn+K7o586tNCkXjGvyoQxDxZniZ2x1+vlzt4Id42Wpl+zIxG4o0z7P56B/pdNbx7sATvccpVlLg== +expo-task-manager@~57.0.20: + version "57.0.20" + resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-57.0.20.tgz#874f2fe8b6a22d158b627094ebe3d1824fe31bbb" + integrity sha512-gfjZPZ3OOD+d5tWnLASH1MwjwcthSFRQBQNBOwa4/bk/JVrGp92kFF8jWzH2iald1YFTOblalcHHhzQzH7NbTA== dependencies: - unimodules-app-loader "~56.0.0" + unimodules-app-loader "~57.0.2" -expo-updates-interface@~56.0.1: - version "56.0.2" - resolved "https://registry.yarnpkg.com/expo-updates-interface/-/expo-updates-interface-56.0.2.tgz#39ad3894535901773419a477cb478df95df9e38c" - integrity sha512-eWTwSZ9y8vrULG2oBn2TQSSIwBGSq/TxGJ3jY6tuVS2FWH/ASRIiKs3zkUZTRoC3ZuV2alz0mUClYV7nNrFx8g== +expo-updates-interface@~57.0.2: + version "57.0.2" + resolved "https://registry.yarnpkg.com/expo-updates-interface/-/expo-updates-interface-57.0.2.tgz#b3d357aee2a618e6e38b275d7de9886776338fd3" + integrity sha512-x3SA5toiHZEkW+5oeNdUM1Rvt6M3ExegQsSwS3Z1+S9gn9utGYTGBkb8MBQivhjIZiRlQYDIhauxA8kySQEWtQ== -expo-video@~56.1.4: - version "56.1.4" - resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-56.1.4.tgz#796426fb2b7b82007a844fa3be6b82611f4757f8" - integrity sha512-8mXZOnb7ZsWneDlcnRf0M2Ox9eb5dGnNiGiCkj9bwLPY+3jM2Pc1t5ymNUuhw8lSpT/rOnE0J0/81Y3+0k2Zcw== +expo-video@~57.0.5: + version "57.0.5" + resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-57.0.5.tgz#d992ac58d2ad765c9b32234327e0037efe4fb11e" + integrity sha512-VK2o+d6rb+tVeL2DI6d0F58pRV8d8bctLpOrMJEHRYtMIK5iwvjjrRx2mciYN8/d04SgWC8TkI+wuZ8Sbp/yuA== -expo-web-browser@~56.0.6: - version "56.0.6" - resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-56.0.6.tgz#35adb93e9f5b27478595ae128c727795c9759a6d" - integrity sha512-+5Zg0JHETAB1+vNu0Bb4+WiUB50MViwcaJKt16sz5GJTrnutcJvwFeXr6oJ7Pw73sp+DjxkIDGSHDq8SAmFRSg== +expo-web-browser@~57.0.3: + version "57.0.3" + resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-57.0.3.tgz#adc332852ef29546de2fa47ba33ce4df9137f98e" + integrity sha512-+ecvhyS/PdWkpv4ai6FF1Trd6Y/4kn/eQ3O86mie2ecU3w+I8bKZh13kppY/jw1uSL7yikt03L5W/iiHojrFwQ== -expo@~56.0.20: - version "56.0.20" - resolved "https://registry.yarnpkg.com/expo/-/expo-56.0.20.tgz#5c20c068d53c27a77b201617dfd6bd9799f11031" - integrity sha512-PCjEnbVK3gssHpODgt7PqCwrLx3ERfWRFrVyi38P8K/UMHD6mPiehuNE786WPxZ5tx8xUtsshKtuBxaUQMu3dw== +expo@~57.0.25: + version "57.0.25" + resolved "https://registry.yarnpkg.com/expo/-/expo-57.0.25.tgz#52278e265bc9ca9e9063e1220920fe4ec3c65b8f" + integrity sha512-oqtX4PekGgU+pBQfsbnbwxh5tKG58o27mN13wTmAhRPlQyK9SHICfCNHxBo+uQyT1BlI3OLhqTwu1qKmHYL5Ug== dependencies: "@babel/runtime" "^7.20.0" - "@expo/cli" "^56.1.24" - "@expo/config" "~56.0.13" - "@expo/config-plugins" "~56.0.15" - "@expo/devtools" "~56.0.2" - "@expo/dom-webview" "~56.0.6" - "@expo/fingerprint" "^0.19.9" - "@expo/local-build-cache-provider" "^56.0.11" - "@expo/log-box" "^56.0.14" - "@expo/metro" "~56.0.0" - "@expo/metro-config" "~56.0.18" + "@expo/cli" "^57.0.27" + "@expo/config" "~57.0.9" + "@expo/config-plugins" "~57.0.9" + "@expo/devtools" "~57.0.1" + "@expo/dom-webview" "~57.0.1" + "@expo/fingerprint" "^0.20.13" + "@expo/local-build-cache-provider" "^57.0.8" + "@expo/log-box" "^57.0.4" + "@expo/metro" "~56.0.2" + "@expo/metro-config" "~57.0.12" "@ungap/structured-clone" "^1.3.0" - babel-preset-expo "~56.0.19" - expo-asset "~56.0.23" - expo-constants "~56.0.24" - expo-file-system "~56.0.10" - expo-font "~56.0.7" - expo-keep-awake "~56.0.3" - expo-modules-autolinking "~56.0.22" - expo-modules-core "~56.0.24" + babel-preset-expo "~57.0.13" + expo-asset "~57.0.18" + expo-constants "~57.0.19" + expo-file-system "~57.0.7" + expo-font "~57.0.4" + expo-keep-awake "~57.0.2" + expo-modules-autolinking "~57.0.13" + expo-modules-core "~57.0.19" pretty-format "^29.7.0" react-refresh "^0.14.2" whatwg-url-minimum "^0.1.2" @@ -9498,32 +9450,30 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" -hermes-compiler@250829098.0.10: - version "250829098.0.10" - resolved "https://registry.yarnpkg.com/hermes-compiler/-/hermes-compiler-250829098.0.10.tgz#b31cae9a2517ee361c73966f76a556a1029af52b" - integrity sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w== +hermes-compiler@250829098.0.17: + version "250829098.0.17" + resolved "https://registry.yarnpkg.com/hermes-compiler/-/hermes-compiler-250829098.0.17.tgz#221c648d86929223f5e3ac5cee760d6a0c2a9bab" + integrity sha512-qG1PXzTEtriF6oQLZF3vyHhSMxOdW5h2TqqLri0rdpstPustd2fSvRZQMVAPdlhgFwBfYnj3OUZtiO6LjYsEFw== hermes-estree@0.20.1: version "0.20.1" resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.20.1.tgz#0b9a544cf883a779a8e1444b915fa365bef7f72d" integrity sha512-SQpZK4BzR48kuOg0v4pb3EAGNclzIlqMj3Opu/mu7bbAoFw6oig6cEt/RAi0zTFW/iW6Iz9X9ggGuZTAZ/yZHg== -hermes-estree@0.33.3: - version "0.33.3" - resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.33.3.tgz#6d6b593d4b471119772c82bdb0212dfadabb6f17" - integrity sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg== - hermes-estree@0.35.0: version "0.35.0" resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.35.0.tgz#767cce0b14a68b4bc06cd5db7efe889f6188c565" integrity sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg== -hermes-parser@0.33.3, hermes-parser@^0.33.3: - version "0.33.3" - resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.33.3.tgz#da50ababb7a5ab636d339e7b2f6e3848e217e09d" - integrity sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA== - dependencies: - hermes-estree "0.33.3" +hermes-estree@0.36.0: + version "0.36.0" + resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.36.0.tgz#5b8c162e986159baf86bc4d01612cff9845ab0f7" + integrity sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w== + +hermes-estree@0.36.1: + version "0.36.1" + resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.36.1.tgz#71368d9e78238728e11ef1f458a8921d0564a572" + integrity sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw== hermes-parser@0.35.0: version "0.35.0" @@ -9532,6 +9482,20 @@ hermes-parser@0.35.0: dependencies: hermes-estree "0.35.0" +hermes-parser@0.36.0: + version "0.36.0" + resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.36.0.tgz#756d6b07301789c5ff0ac3ac7ff6bffa8be7098c" + integrity sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w== + dependencies: + hermes-estree "0.36.0" + +hermes-parser@0.36.1, hermes-parser@^0.36.0: + version "0.36.1" + resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.36.1.tgz#f619b9f99bf34e80fb6f7024b1c62944d2beb14a" + integrity sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ== + dependencies: + hermes-estree "0.36.1" + hermes-parser@^0.20.1: version "0.20.1" resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.20.1.tgz#ad10597b99f718b91e283f81cbe636c50c3cff92" @@ -10604,10 +10568,10 @@ jest-environment-node@^29.7.0: jest-mock "^29.7.0" jest-util "^29.7.0" -jest-expo@~56.0.5: - version "56.0.5" - resolved "https://registry.yarnpkg.com/jest-expo/-/jest-expo-56.0.5.tgz#e97d1a038392f0ad88f938ec1e0bcffce0876dd1" - integrity sha512-dlGKNy21OKFZ5ynNmKMS5eI1zzaNmOLvwX+rEt7PVXfPKuQ1VJLy6YOKZVCuh8vhSm27VGTa/HX3OjyGJ1FP1g== +jest-expo@~57.0.5: + version "57.0.5" + resolved "https://registry.yarnpkg.com/jest-expo/-/jest-expo-57.0.5.tgz#149c0b57cc309614ea2ceb39ce622b6dcb19155a" + integrity sha512-U08fwjaDBwnnbKnZcOA0WQbHfKqOWTCGku+IdCaekrLZmfbbZr3P1WqEsXHo6ntidQ3DmW+TT6G1aOp+ChYfKA== dependencies: "@jest/create-cache-key-function" "^29.2.1" "@jest/globals" "^29.2.1" @@ -11784,6 +11748,17 @@ metro-babel-transformer@0.84.4: metro-cache-key "0.84.4" nullthrows "^1.1.1" +metro-babel-transformer@0.84.5: + version "0.84.5" + resolved "https://registry.yarnpkg.com/metro-babel-transformer/-/metro-babel-transformer-0.84.5.tgz#38610674043edb8e007139adaf3c13082914f35d" + integrity sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ== + dependencies: + "@babel/core" "^7.25.2" + flow-enums-runtime "^0.0.6" + hermes-parser "0.35.0" + metro-cache-key "0.84.5" + nullthrows "^1.1.1" + metro-cache-key@0.84.4: version "0.84.4" resolved "https://registry.yarnpkg.com/metro-cache-key/-/metro-cache-key-0.84.4.tgz#52059d32da68ef2e06d5a3d4770defdc6d8bad77" @@ -11791,6 +11766,13 @@ metro-cache-key@0.84.4: dependencies: flow-enums-runtime "^0.0.6" +metro-cache-key@0.84.5: + version "0.84.5" + resolved "https://registry.yarnpkg.com/metro-cache-key/-/metro-cache-key-0.84.5.tgz#f4eea64f8a78ad626c335493fc8d0edce65f9b7d" + integrity sha512-3dPB2TnvGjjf0/9O7AXVQURKXuQNauTZE7WpTGTlR017Gh/B5y0m/2wcqxfveUguHSpu89KhVxCAlr2k/H7uhQ== + dependencies: + flow-enums-runtime "^0.0.6" + metro-cache@0.84.4: version "0.84.4" resolved "https://registry.yarnpkg.com/metro-cache/-/metro-cache-0.84.4.tgz#026476edb629a84650efd7b4245bbc600b3b0eb7" @@ -11801,6 +11783,16 @@ metro-cache@0.84.4: https-proxy-agent "^7.0.5" metro-core "0.84.4" +metro-cache@0.84.5: + version "0.84.5" + resolved "https://registry.yarnpkg.com/metro-cache/-/metro-cache-0.84.5.tgz#cc622b62b6d51b69bd8a2ea3f8a86076f12dc445" + integrity sha512-WHS0n2OxQqtwEjSeQFPePNrMvEFhmQcUQM9cRJMHByWoi/GMWFBEWOf7hVkAM/0KRutAXNbDlSu/cZB6CyxgQQ== + dependencies: + exponential-backoff "^3.1.1" + flow-enums-runtime "^0.0.6" + https-proxy-agent "^7.0.5" + metro-core "0.84.5" + metro-config@0.84.4, metro-config@^0.84.3: version "0.84.4" resolved "https://registry.yarnpkg.com/metro-config/-/metro-config-0.84.4.tgz#456190b984a4ce16eb10448a3c726880cec895e9" @@ -11815,6 +11807,20 @@ metro-config@0.84.4, metro-config@^0.84.3: metro-runtime "0.84.4" yaml "^2.6.1" +metro-config@0.84.5: + version "0.84.5" + resolved "https://registry.yarnpkg.com/metro-config/-/metro-config-0.84.5.tgz#a45b1556bd61393cd4853393ac2b11eefdd7a217" + integrity sha512-zie+uN6oohscowi2S7ByU+wUw6CrT4ZxW9uAbONOObSxx86RGmnIAmjXHLkfmcdYoY7jzOPEbqcI6oeVmqyBQA== + dependencies: + connect "^3.6.5" + flow-enums-runtime "^0.0.6" + jest-validate "^29.7.0" + metro "0.84.5" + metro-cache "0.84.5" + metro-core "0.84.5" + metro-runtime "0.84.5" + yaml "^2.6.1" + metro-core@0.84.4, metro-core@^0.84.3: version "0.84.4" resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.84.4.tgz#e4afffab9bd2e9b304c89c59322344d59911c31b" @@ -11824,6 +11830,15 @@ metro-core@0.84.4, metro-core@^0.84.3: lodash.throttle "^4.1.1" metro-resolver "0.84.4" +metro-core@0.84.5: + version "0.84.5" + resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.84.5.tgz#8a7c9f7156df82cc2b9cae75504e9546d637bb7e" + integrity sha512-xwm605hCi5Y6eJTTb8ZWo6pkUcoBEIyiQOfkZh5GwtDwUrP9SNhTQZhzJHrBCwwxlf3Ptl/pxWJgQ1rsNYMnrA== + dependencies: + flow-enums-runtime "^0.0.6" + lodash.throttle "^4.1.1" + metro-resolver "0.84.5" + metro-file-map@0.84.4: version "0.84.4" resolved "https://registry.yarnpkg.com/metro-file-map/-/metro-file-map-0.84.4.tgz#00df08834ee4d34a8c15e33419fdff5a76a34870" @@ -11839,6 +11854,21 @@ metro-file-map@0.84.4: nullthrows "^1.1.1" walker "^1.0.7" +metro-file-map@0.84.5: + version "0.84.5" + resolved "https://registry.yarnpkg.com/metro-file-map/-/metro-file-map-0.84.5.tgz#c3d76a90012dbd05a7da461364d8939b407e7f7d" + integrity sha512-mlm/JL8toSbSc2akpKIGmzvrVRSCgZ5vkbycI34oMLoOnLGuLyC8WTyVJ6P0hZG/usDaGwZSl/s9BCRriqjGJA== + dependencies: + debug "^4.4.0" + fb-watchman "^2.0.0" + flow-enums-runtime "^0.0.6" + graceful-fs "^4.2.4" + invariant "^2.2.4" + jest-worker "^29.7.0" + micromatch "^4.0.4" + nullthrows "^1.1.1" + walker "^1.0.7" + metro-minify-terser@0.84.4: version "0.84.4" resolved "https://registry.yarnpkg.com/metro-minify-terser/-/metro-minify-terser-0.84.4.tgz#e904d63f085d1af33587ff6daf5010243d19eec9" @@ -11847,6 +11877,14 @@ metro-minify-terser@0.84.4: flow-enums-runtime "^0.0.6" terser "^5.15.0" +metro-minify-terser@0.84.5: + version "0.84.5" + resolved "https://registry.yarnpkg.com/metro-minify-terser/-/metro-minify-terser-0.84.5.tgz#796a4986e172537d2b0d08358a96df1dc2428e60" + integrity sha512-BJoFwCEDsYnagPqarayInv2+diCDNDdLlaof/p6s9w4gh+gc9HXYM+pDvsKGKKUumpZswNF3Z/ftTMqKl/5IBg== + dependencies: + flow-enums-runtime "^0.0.6" + terser "^5.15.0" + metro-resolver@0.84.4: version "0.84.4" resolved "https://registry.yarnpkg.com/metro-resolver/-/metro-resolver-0.84.4.tgz#88181301abb9b476072e8263f7b3b918d493931a" @@ -11854,6 +11892,13 @@ metro-resolver@0.84.4: dependencies: flow-enums-runtime "^0.0.6" +metro-resolver@0.84.5: + version "0.84.5" + resolved "https://registry.yarnpkg.com/metro-resolver/-/metro-resolver-0.84.5.tgz#bc2d8aa10eb9ec9044dec099dc6f4c28f1306f3a" + integrity sha512-VSSnepg1k6LyCwtb6eirWdAWlpKwBG8Rdtsr1mU38rMelFyWgh3/QuMSiZIZAIjwg/fsa8GhW5/FO54CAUPCEA== + dependencies: + flow-enums-runtime "^0.0.6" + metro-runtime@0.84.4, metro-runtime@^0.84.3: version "0.84.4" resolved "https://registry.yarnpkg.com/metro-runtime/-/metro-runtime-0.84.4.tgz#8c10a488d19a49d64bcc775039032b61cceb26a6" @@ -11862,6 +11907,14 @@ metro-runtime@0.84.4, metro-runtime@^0.84.3: "@babel/runtime" "^7.25.0" flow-enums-runtime "^0.0.6" +metro-runtime@0.84.5: + version "0.84.5" + resolved "https://registry.yarnpkg.com/metro-runtime/-/metro-runtime-0.84.5.tgz#2c13f2177f5d3951f7f62188c223e84a520ed87c" + integrity sha512-U1m2+d1Pr+JO2/iVXBB2OfXXityz7tqwIorxfrT15IEgaHvpJBq/OHiqnOWPKJbUl3JcxjcdviZZOKk85oK4Qg== + dependencies: + "@babel/runtime" "^7.25.0" + flow-enums-runtime "^0.0.6" + metro-source-map@0.84.4, metro-source-map@^0.84.3: version "0.84.4" resolved "https://registry.yarnpkg.com/metro-source-map/-/metro-source-map-0.84.4.tgz#e16e7c2dce22d8aea527f459ba88f5a2827ba32a" @@ -11877,6 +11930,21 @@ metro-source-map@0.84.4, metro-source-map@^0.84.3: source-map "^0.5.6" vlq "^1.0.0" +metro-source-map@0.84.5: + version "0.84.5" + resolved "https://registry.yarnpkg.com/metro-source-map/-/metro-source-map-0.84.5.tgz#1338cc7ca3666dc126eb5ef4b3514a6a8b8b7f05" + integrity sha512-2BtV5L9uPc49F13Gn5wiP6bX/EncqzqTIk2VL/0F/96Vo0YEOjluT/qktQjFODfqGFsucwnh5mPEAl/2jVEfeg== + dependencies: + "@babel/traverse" "^7.29.0" + "@babel/types" "^7.29.0" + flow-enums-runtime "^0.0.6" + invariant "^2.2.4" + metro-symbolicate "0.84.5" + nullthrows "^1.1.1" + ob1 "0.84.5" + source-map "^0.5.6" + vlq "^1.0.0" + metro-symbolicate@0.84.4: version "0.84.4" resolved "https://registry.yarnpkg.com/metro-symbolicate/-/metro-symbolicate-0.84.4.tgz#0d5a9fd2f15b5c079050051d8bd1f88726d6e0d2" @@ -11889,6 +11957,18 @@ metro-symbolicate@0.84.4: source-map "^0.5.6" vlq "^1.0.0" +metro-symbolicate@0.84.5: + version "0.84.5" + resolved "https://registry.yarnpkg.com/metro-symbolicate/-/metro-symbolicate-0.84.5.tgz#dc8224940fc59ed8b934b7ab8b63fc68c081e0bf" + integrity sha512-rQ40zYDAkaWBN9yvjUuAD0ZpzBMZSoKyGYXnb5JrfbKjun7fTvfoLHL3KXFYenBTYZkQtlp4cKSCv/1utxFyOw== + dependencies: + flow-enums-runtime "^0.0.6" + invariant "^2.2.4" + metro-source-map "0.84.5" + nullthrows "^1.1.1" + source-map "^0.5.6" + vlq "^1.0.0" + metro-transform-plugins@0.84.4: version "0.84.4" resolved "https://registry.yarnpkg.com/metro-transform-plugins/-/metro-transform-plugins-0.84.4.tgz#b5a4e4329b1ee0261fb21f8910df7eefd02e685d" @@ -11901,6 +11981,18 @@ metro-transform-plugins@0.84.4: flow-enums-runtime "^0.0.6" nullthrows "^1.1.1" +metro-transform-plugins@0.84.5: + version "0.84.5" + resolved "https://registry.yarnpkg.com/metro-transform-plugins/-/metro-transform-plugins-0.84.5.tgz#fab2aa3e5cae11d414c871feae8cafa1c7f746f7" + integrity sha512-+InaSVGaOyt0DyRo4Y/zIdPI6CZwnbNho5LAL23tgmuGwv7fyfkF7kKfPjZcfxXBcoYdTLLFnCfCH/dHSiCqNg== + dependencies: + "@babel/core" "^7.25.2" + "@babel/generator" "^7.29.1" + "@babel/template" "^7.28.6" + "@babel/traverse" "^7.29.0" + flow-enums-runtime "^0.0.6" + nullthrows "^1.1.1" + metro-transform-worker@0.84.4: version "0.84.4" resolved "https://registry.yarnpkg.com/metro-transform-worker/-/metro-transform-worker-0.84.4.tgz#2a66d65d147f7aa7d13c8620e65474095f46b441" @@ -11920,6 +12012,25 @@ metro-transform-worker@0.84.4: metro-transform-plugins "0.84.4" nullthrows "^1.1.1" +metro-transform-worker@0.84.5: + version "0.84.5" + resolved "https://registry.yarnpkg.com/metro-transform-worker/-/metro-transform-worker-0.84.5.tgz#57543a44e1054d1adbfa054d1a7a907981c1c455" + integrity sha512-ui1Z8x4s5RL36gMmKLaMMO7O9NNDHNdthEZSCDQHAau3JcAsTaFOK6I+2q4I/kW5u8hSEjJk9L45TXSVJw6g1A== + dependencies: + "@babel/core" "^7.25.2" + "@babel/generator" "^7.29.1" + "@babel/parser" "^7.29.0" + "@babel/types" "^7.29.0" + flow-enums-runtime "^0.0.6" + metro "0.84.5" + metro-babel-transformer "0.84.5" + metro-cache "0.84.5" + metro-cache-key "0.84.5" + metro-minify-terser "0.84.5" + metro-source-map "0.84.5" + metro-transform-plugins "0.84.5" + nullthrows "^1.1.1" + metro@0.84.4, metro@^0.84.3: version "0.84.4" resolved "https://registry.yarnpkg.com/metro/-/metro-0.84.4.tgz#dfcf92b3bcadce7cafaf1a1fa231b6490dfae78c" @@ -11965,6 +12076,50 @@ metro@0.84.4, metro@^0.84.3: ws "^7.5.10" yargs "^17.6.2" +metro@0.84.5: + version "0.84.5" + resolved "https://registry.yarnpkg.com/metro/-/metro-0.84.5.tgz#c7402b9a5a80fa22501705e7d121f59d866bf06f" + integrity sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw== + dependencies: + "@babel/code-frame" "^7.29.0" + "@babel/core" "^7.25.2" + "@babel/generator" "^7.29.1" + "@babel/parser" "^7.29.0" + "@babel/template" "^7.28.6" + "@babel/traverse" "^7.29.0" + "@babel/types" "^7.29.0" + accepts "^2.0.0" + ci-info "^2.0.0" + connect "^3.6.5" + debug "^4.4.0" + error-stack-parser "^2.0.6" + flow-enums-runtime "^0.0.6" + graceful-fs "^4.2.4" + hermes-parser "0.35.0" + invariant "^2.2.4" + jest-worker "^29.7.0" + jsc-safe-url "^0.2.2" + lodash.throttle "^4.1.1" + metro-babel-transformer "0.84.5" + metro-cache "0.84.5" + metro-cache-key "0.84.5" + metro-config "0.84.5" + metro-core "0.84.5" + metro-file-map "0.84.5" + metro-resolver "0.84.5" + metro-runtime "0.84.5" + metro-source-map "0.84.5" + metro-symbolicate "0.84.5" + metro-transform-plugins "0.84.5" + metro-transform-worker "0.84.5" + mime-types "^3.0.1" + nullthrows "^1.1.1" + serialize-error "^2.1.0" + source-map "^0.5.6" + throat "^5.0.0" + ws "^7.5.10" + yargs "^17.6.2" + micromatch@^4.0.0, micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.8, micromatch@~4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" @@ -12234,7 +12389,7 @@ mute-stream@1.0.0: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-1.0.0.tgz#e31bd9fe62f0aed23520aa4324ea6671531e013e" integrity sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA== -nanoid@^3.3.1, nanoid@^3.3.11, nanoid@^3.3.8: +nanoid@^3.3.1, nanoid@^3.3.11: version "3.3.11" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== @@ -12249,6 +12404,11 @@ nanoid@^3.3.17: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== +nanoid@^3.3.18: + version "3.3.19" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.19.tgz#336d4aa4bcd4fb24d2cddede7ffeae40bec03f0a" + integrity sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug== + napi-postinstall@^0.3.0: version "0.3.3" resolved "https://registry.yarnpkg.com/napi-postinstall/-/napi-postinstall-0.3.3.tgz#93d045c6b576803ead126711d3093995198c6eb9" @@ -12504,6 +12664,13 @@ ob1@0.84.4: dependencies: flow-enums-runtime "^0.0.6" +ob1@0.84.5: + version "0.84.5" + resolved "https://registry.yarnpkg.com/ob1/-/ob1-0.84.5.tgz#5b103040a271723e555f77be3d54aaf4c32d9db5" + integrity sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A== + dependencies: + flow-enums-runtime "^0.0.6" + object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -13507,10 +13674,10 @@ react-native-flash-message@0.4.2: prop-types "^15.8.1" react-native-iphone-screen-helper "^2.0.2" -react-native-gesture-handler@~2.31.1: - version "2.31.2" - resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.31.2.tgz#f1832592619db00d70b036f73cf48f54cb415d71" - integrity sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A== +react-native-gesture-handler@~2.32.0: + version "2.32.0" + resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.32.0.tgz#59b8658a4e1586ce6fb0ca0053ac80f6acf7f882" + integrity sha512-uYIMOKlKENORq2SABE+jIjbPU+h5I/sQKcq2v16zRq848nwEp1fWRVwML4QWqijc8UcXJC25o54S8GQd4Mf2OA== dependencies: "@egjs/hammerjs" "^2.0.17" "@types/react-test-renderer" "^19.1.0" @@ -13532,10 +13699,10 @@ react-native-is-edge-to-edge@^1.3.1: resolved "https://registry.yarnpkg.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz#feb9a6a8faf0874298947edd556e5af22044e139" integrity sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA== -react-native-keyboard-controller@1.21.6: - version "1.21.6" - resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.6.tgz#0225f1e83fdfde40bc82df27d99a02a5fe748230" - integrity sha512-nAXCmar/W8Gn4iQV7O5fAVuTh57JszCsqTS+cfR95WFOLR/AfbwfPz/+sWyz/q2SOIe2VpyQzq6hzYiwErhqqw== +react-native-keyboard-controller@1.21.9: + version "1.21.9" + resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.9.tgz#2859601d8936355eb90d760c60f28a200782221d" + integrity sha512-+TkkFldht4+AXBQeDy1hLE7iqiW8/NkY/ekhcFsKIiRdI9qC5JDzx0TfAg1iYZB2IeOXppmURIy2jFCUjOcV1w== dependencies: react-native-is-edge-to-edge "^1.2.1" @@ -13549,10 +13716,10 @@ react-native-mmkv@3.1.0: resolved "https://registry.yarnpkg.com/react-native-mmkv/-/react-native-mmkv-3.1.0.tgz#4b2c321cf11bde2f9da32acf76e0178ecd332ccc" integrity sha512-HDh89nYVSufHMweZ3TVNUHQp2lsEh1ApaoV08bUOU1nrlmGgC3I7tGUn1Uy40Hs7yRMPKx5NWKE5Dh86jTVrwg== -react-native-reanimated@4.3.1: - version "4.3.1" - resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-4.3.1.tgz#dd390567f27a9245ae431d4992d9787a597ec914" - integrity sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q== +react-native-reanimated@4.5.1: + version "4.5.1" + resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz#be81ed3a96bf70baeeccdc24d3350883099b7dd3" + integrity sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA== dependencies: react-native-is-edge-to-edge "^1.3.1" semver "^7.7.3" @@ -13623,43 +13790,44 @@ react-native-webview@13.16.1: escape-string-regexp "^4.0.0" invariant "2.2.4" -react-native-worklets@0.8.3: - version "0.8.3" - resolved "https://registry.yarnpkg.com/react-native-worklets/-/react-native-worklets-0.8.3.tgz#87014dbe148f76a8ffd623953d3614a54aa880e8" - integrity sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg== +react-native-worklets@0.10.1: + version "0.10.1" + resolved "https://registry.yarnpkg.com/react-native-worklets/-/react-native-worklets-0.10.1.tgz#7a3038a3b5ec66cab030a00e1568b6599696f5f2" + integrity sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA== dependencies: "@babel/plugin-transform-arrow-functions" "^7.27.1" - "@babel/plugin-transform-class-properties" "^7.27.1" - "@babel/plugin-transform-classes" "^7.28.4" - "@babel/plugin-transform-nullish-coalescing-operator" "^7.27.1" - "@babel/plugin-transform-optional-chaining" "^7.27.1" + "@babel/plugin-transform-class-properties" "^7.28.6" + "@babel/plugin-transform-classes" "^7.28.6" + "@babel/plugin-transform-nullish-coalescing-operator" "^7.28.6" + "@babel/plugin-transform-optional-chaining" "^7.28.6" "@babel/plugin-transform-shorthand-properties" "^7.27.1" "@babel/plugin-transform-template-literals" "^7.27.1" "@babel/plugin-transform-unicode-regex" "^7.27.1" - "@babel/preset-typescript" "^7.27.1" + "@babel/preset-typescript" "^7.28.5" + "@babel/types" "^7.27.1" convert-source-map "^2.0.0" - semver "^7.7.3" - -react-native@0.85.3: - version "0.85.3" - resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.85.3.tgz#4a3d25c63a2fd320ffb093e20269cf47d585d483" - integrity sha512-HN/fGC+3nZVcDNcw7gfbM/DuqZAvI9Mz+/SxuhODaua4JY0BPzhfTzWXRyTR4mRgMHmShTPpH2PYMTxvZrsdZA== - dependencies: - "@react-native/assets-registry" "0.85.3" - "@react-native/codegen" "0.85.3" - "@react-native/community-cli-plugin" "0.85.3" - "@react-native/gradle-plugin" "0.85.3" - "@react-native/js-polyfills" "0.85.3" - "@react-native/normalize-colors" "0.85.3" - "@react-native/virtualized-lists" "0.85.3" + semver "^7.7.4" + +react-native@0.86.3: + version "0.86.3" + resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.86.3.tgz#39f40d11dc2ca6d56262cdf1cb9e5ac1cfc8218b" + integrity sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q== + dependencies: + "@react-native/assets-registry" "0.86.3" + "@react-native/codegen" "0.86.3" + "@react-native/community-cli-plugin" "0.86.3" + "@react-native/gradle-plugin" "0.86.3" + "@react-native/js-polyfills" "0.86.3" + "@react-native/normalize-colors" "0.86.3" + "@react-native/virtualized-lists" "0.86.3" abort-controller "^3.0.0" anser "^1.4.9" ansi-regex "^5.0.0" - babel-plugin-syntax-hermes-parser "0.33.3" + babel-plugin-syntax-hermes-parser "0.36.0" base64-js "^1.5.1" commander "^12.0.0" flow-enums-runtime "^0.0.6" - hermes-compiler "250829098.0.10" + hermes-compiler "250829098.0.17" invariant "^2.2.4" memoize-one "^5.0.0" metro-runtime "^0.84.3" @@ -14260,6 +14428,11 @@ semver@^7.7.3: resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== +semver@^7.7.4: + version "7.8.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" + integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== + send@0.19.0: version "0.19.0" resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8" @@ -15633,10 +15806,10 @@ unicorn-magic@^0.1.0: resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.1.0.tgz#1bb9a51c823aaf9d73a8bfcd3d1a23dde94b0ce4" integrity sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ== -unimodules-app-loader@~56.0.0: - version "56.0.1" - resolved "https://registry.yarnpkg.com/unimodules-app-loader/-/unimodules-app-loader-56.0.1.tgz#443b9b17c8ca04733eb1f5572cf0c944024373b5" - integrity sha512-Z801jeBOQMUF/ExklxT1BqhEV/oF2/Bii7PFYAj/8Sauxl7oKvZbf70peRzzAU0mG7UQ3yU/UO/EpD1JyJ2WcA== +unimodules-app-loader@~57.0.2: + version "57.0.2" + resolved "https://registry.yarnpkg.com/unimodules-app-loader/-/unimodules-app-loader-57.0.2.tgz#950a025d13c71c6202069447c10466dcba606001" + integrity sha512-1Ja+r1VR556m1cTn4BBwAe0AQpX8mDobvN+6Kx12h0jqf0euHfvkPHg83ijuC7pWHff9vTOUc7qr5t8vnmnljg== unique-filename@^4.0.0: version "4.0.0" From 69fc089e1fb9f059b15ae017f1c8d6cdbaeb5fef Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 25 Sep 2026 18:23:12 -0700 Subject: [PATCH 3/8] RU-T53 Lint fix --- src/services/offline-event-manager.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/services/offline-event-manager.service.ts b/src/services/offline-event-manager.service.ts index bf216927..4e9ba763 100644 --- a/src/services/offline-event-manager.service.ts +++ b/src/services/offline-event-manager.service.ts @@ -17,6 +17,7 @@ import { } from '@/models/offline-queue/queued-event'; import { SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput'; import { SaveUnitStatusInput, SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; +import type * as ChecklistsStore from '@/stores/checklists/store'; import { setOfflineQueueActivityListener, useOfflineQueueStore } from '@/stores/offline-queue/store'; import { isNetworkError } from '@/utils/network'; @@ -432,7 +433,7 @@ class OfflineEventManager { switch (event.type) { case QueuedEventType.CHECKLIST_COMPLETION: { // eslint-disable-next-line @typescript-eslint/no-require-imports - const { flushChecklistDraft } = require('@/stores/checklists/store') as typeof import('@/stores/checklists/store'); + const { flushChecklistDraft } = require('@/stores/checklists/store') as typeof ChecklistsStore; if (typeof event.data.scope !== 'string' || typeof event.data.id !== 'string') throw new Error('checklist_invalid_reference'); await flushChecklistDraft(event.data.scope, event.data.id); break; From a30f7b5d7455f82898d2e78ef41a08263c648d4d Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 25 Sep 2026 21:15:42 -0700 Subject: [PATCH 4/8] RU-T53 PR #287 fixes --- src/app/records/[id].tsx | 8 +- src/app/records/new.tsx | 15 +-- src/components/calls/call-files-modal.tsx | 6 +- .../contacts/contact-files-list.tsx | 5 +- .../__tests__/records-quick-create.test.tsx | 95 +++++++++++++++++++ src/components/records/record-attachments.tsx | 4 +- .../records/records-quick-create.tsx | 7 +- .../server-url-bottom-sheet.test.tsx | 18 ++++ .../settings/server-url-bottom-sheet.tsx | 25 +++-- src/lib/contacts/__tests__/format.test.ts | 4 + src/lib/contacts/format.ts | 18 +++- src/lib/records/__tests__/uploads.test.ts | 27 +++++- src/lib/records/uploads.ts | 14 ++- .../__tests__/app-reset.service.test.ts | 38 ++++++++ src/services/app-reset.service.ts | 14 +++ src/stores/app/core-store.ts | 2 +- .../calls/__tests__/site-info-store.test.ts | 26 +++++ src/stores/calls/site-info-store.ts | 17 +++- src/stores/checklists/__tests__/store.test.ts | 17 ++++ src/stores/checklists/store.ts | 28 +++++- src/stores/operations/__tests__/store.test.ts | 18 ++++ src/stores/operations/store.ts | 16 +++- .../__tests__/deployments-store.test.ts | 17 ++++ src/stores/records/__tests__/store.test.ts | 19 ++++ src/stores/records/deployments-store.ts | 18 +++- src/stores/records/store.ts | 30 +++--- src/translations/en.json | 2 +- src/utils/__tests__/file-name.test.ts | 25 +++++ src/utils/file-name.ts | 14 +++ 29 files changed, 490 insertions(+), 57 deletions(-) create mode 100644 src/components/records/__tests__/records-quick-create.test.tsx create mode 100644 src/utils/__tests__/file-name.test.ts create mode 100644 src/utils/file-name.ts diff --git a/src/app/records/[id].tsx b/src/app/records/[id].tsx index cdebc466..beddcaa2 100644 --- a/src/app/records/[id].tsx +++ b/src/app/records/[id].tsx @@ -85,7 +85,7 @@ export default function RecordScreen() { setIsBusy(true); try { const clientRecordId = `edit-${record.RecordId}-${record.RowVersion}`; - stageDraft({ + const draft = { clientRecordId, recordId: record.RecordId, definitionKey: record.DefinitionKey ?? '', @@ -94,8 +94,10 @@ export default function RecordScreen() { values: toValueList(values), rowVersion: record.RowVersion, updatedOn: new Date().toISOString(), - }); - const result = await pushDraft(clientRecordId); + }; + stageDraft(draft); + // Passed directly as well: a definition that seals values is never staged on the device. + const result = await pushDraft(clientRecordId, draft); if (result.ok) { discardDraft(clientRecordId); setMessage(t('records.saved')); diff --git a/src/app/records/new.tsx b/src/app/records/new.tsx index ba50d834..dcac08f8 100644 --- a/src/app/records/new.tsx +++ b/src/app/records/new.tsx @@ -15,7 +15,6 @@ import { Pressable } from '@/components/ui/pressable'; import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; -import { useRecordsContext } from '@/hooks/use-records-context'; import { canAuthorOffline, cellKey, toValueList, unsupportedFieldKeys, validate, type ValidationIssue, type ValueMap } from '@/lib/records/schema'; import { type FieldRecordCatalogEntry, type RecordDefinitionSchema } from '@/models/v4/records'; import { useRecordsStore } from '@/stores/records/store'; @@ -29,7 +28,9 @@ export default function NewRecordScreen() { const { t } = useTranslation(); const router = useRouter(); const params = useLocalSearchParams<{ draft?: string; definitionKey?: string }>(); - const context = useRecordsContext(); + // The context the entry point set (the Records tab, or a Call being viewed): the catalog and the + // prefill were loaded against it, so the draft is attached to the same Call rather than the active one. + const context = useRecordsStore((state) => state.context); const catalog = useRecordsStore((state) => state.catalog); const pendingDrafts = useRecordsStore((state) => state.pendingDrafts); @@ -132,8 +133,9 @@ export default function NewRecordScreen() { definitionVersion: entry.Version, name: entry.Name, values: toValueList(values), - callId: context.CallId ?? null, - stationGroupId: context.GroupId ?? null, + // A resumed draft stays on the Call it was started against, whatever is active now. + callId: resumed ? (resumed.callId ?? null) : (context.CallId ?? null), + stationGroupId: resumed ? (resumed.stationGroupId ?? null) : (context.GroupId ?? null), rowVersion: resumed?.rowVersion ?? null, updatedOn: new Date().toISOString(), }; @@ -148,9 +150,10 @@ export default function NewRecordScreen() { setIsBusy(true); try { - // Staged first so an interrupted send leaves the work on the device rather than losing it. + // Staged first so an interrupted send leaves the work on the device rather than losing it. A + // definition that seals values is not staged, so the draft is handed to the send directly. stageDraft(draft); - const result = await pushDraft(draftId); + const result = await pushDraft(draftId, draft); if (result.ok && result.recordId) { discardDraft(draftId); router.replace(`/records/${result.recordId}`); diff --git a/src/components/calls/call-files-modal.tsx b/src/components/calls/call-files-modal.tsx index b6d885f4..ec0544ce 100644 --- a/src/components/calls/call-files-modal.tsx +++ b/src/components/calls/call-files-modal.tsx @@ -20,6 +20,7 @@ import { useAnalytics } from '@/hooks/use-analytics'; import { logger } from '@/lib/logging'; import { type CallFileResultData } from '@/models/v4/callFiles/callFileResultData'; import { useCallDetailStore } from '@/stores/calls/detail-store'; +import { safeFileName } from '@/utils/file-name'; import { FocusAwareStatusBar } from '../ui'; @@ -111,8 +112,9 @@ export const CallFilesModal: React.FC = ({ isOpen, onClose, try { setDownloadingFiles((prev) => ({ ...prev, [file.Id]: 0 })); - // Create a temporary file - const fileName = file.FileName || file.Name || `file_${file.Id}`; + // Create a temporary file. The name comes from whoever uploaded it, so it is reduced to one + // path segment before it is joined to the documents directory. + const fileName = safeFileName(file.FileName || file.Name, `file_${file.Id}`); if (!documentDirectory) { throw new Error('Document directory is unavailable'); } diff --git a/src/components/contacts/contact-files-list.tsx b/src/components/contacts/contact-files-list.tsx index 3dbceaf9..abc2baec 100644 --- a/src/components/contacts/contact-files-list.tsx +++ b/src/components/contacts/contact-files-list.tsx @@ -17,6 +17,7 @@ import { useAnalytics } from '@/hooks/use-analytics'; import { isFieldRedacted } from '@/lib/data-protection/redacted'; import { logger } from '@/lib/logging'; import { type ContactFileResultData } from '@/models/v4/contactFiles/contactFilesResult'; +import { safeFileName } from '@/utils/file-name'; /** ADP catalog v12 field ids for a contact file's cataloged text. */ const FileFieldIds = { @@ -55,7 +56,9 @@ export const ContactFilesList: React.FC = ({ files, isLoa try { const base64 = await getContactFileBase64(file); - const fileName = (!isFieldRedacted(file.RedactedFields, FileFieldIds.fileName, file.FileName) && file.FileName) || `contact_file_${file.Id}`; + // The name comes from whoever uploaded the file, so it is reduced to one path segment before it + // is joined to the documents directory. + const fileName = safeFileName(isFieldRedacted(file.RedactedFields, FileFieldIds.fileName, file.FileName) ? null : file.FileName, `contact_file_${file.Id}`); const fileUri = `${FileSystem.documentDirectory}${fileName}`; await FileSystem.writeAsStringAsync(fileUri, base64, { encoding: FileSystem.EncodingType.Base64 }); diff --git a/src/components/records/__tests__/records-quick-create.test.tsx b/src/components/records/__tests__/records-quick-create.test.tsx new file mode 100644 index 00000000..f48db25e --- /dev/null +++ b/src/components/records/__tests__/records-quick-create.test.tsx @@ -0,0 +1,95 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { type FieldRecordContextInput } from '@/models/v4/records'; + +import { RecordsQuickCreate } from '../records-quick-create'; + +const mockPush = jest.fn(); +const mockSetContext = jest.fn(); +const mockFetchCatalog = jest.fn().mockResolvedValue(null); +let mockFlagStatus = 'enabled'; +let mockCatalog: { Definitions: { DefinitionKey: string }[] } | null = null; + +jest.mock('expo-router', () => ({ + router: { push: (...args: unknown[]) => mockPush(...args) }, +})); + +jest.mock('lucide-react-native', () => ({ FilePlus2: 'FilePlus2' })); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('@/components/ui/button', () => { + const React = require('react'); + const { Text, TouchableOpacity } = require('react-native'); + return { + Button: ({ children, onPress, testID }: { children: React.ReactNode; onPress: () => void; testID?: string }) => React.createElement(TouchableOpacity, { onPress, testID }, children), + ButtonIcon: () => null, + ButtonText: ({ children }: { children: React.ReactNode }) => React.createElement(Text, {}, children), + }; +}); + +jest.mock('@/stores/feature-flags/store', () => ({ + useRecordsFieldStatus: () => mockFlagStatus, +})); + +jest.mock('@/stores/records/store', () => ({ + useRecordsStore: (selector: (state: unknown) => unknown) => selector({ setContext: mockSetContext, fetchCatalog: mockFetchCatalog, catalog: mockCatalog }), +})); + +describe('RecordsQuickCreate', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFlagStatus = 'enabled'; + mockCatalog = null; + }); + + it('fetches the catalog once while re-rendered with an equal but new context object', () => { + const context = (): FieldRecordContextInput => ({ UnitId: 3, CallId: 42 }); + const { rerender, unmount } = render(); + + rerender(); + rerender(); + + expect(mockFetchCatalog).toHaveBeenCalledTimes(1); + expect(mockSetContext).toHaveBeenCalledTimes(1); + expect(mockSetContext).toHaveBeenCalledWith(expect.objectContaining({ UnitId: 3, CallId: 42 })); + unmount(); + }); + + it('fetches again when the context actually changes', () => { + const { rerender, unmount } = render(); + + rerender(); + + expect(mockFetchCatalog).toHaveBeenCalledTimes(2); + expect(mockSetContext).toHaveBeenLastCalledWith(expect.objectContaining({ UnitId: 3, CallId: 43 })); + unmount(); + }); + + it('does nothing and renders nothing while Field Records is not enabled', () => { + mockFlagStatus = 'disabled'; + mockCatalog = { Definitions: [{ DefinitionKey: 'shift-log' }] }; + + const { unmount } = render(); + + expect(mockFetchCatalog).not.toHaveBeenCalled(); + expect(mockSetContext).not.toHaveBeenCalled(); + expect(screen.queryByTestId('records-quick-create')).toBeNull(); + unmount(); + }); + + it('hides the button until the server offers a definition, then opens a new record', () => { + const { rerender, unmount } = render(); + expect(screen.queryByTestId('records-quick-create')).toBeNull(); + + mockCatalog = { Definitions: [{ DefinitionKey: 'shift-log' }] }; + rerender(); + fireEvent.press(screen.getByTestId('records-quick-create')); + + expect(mockPush).toHaveBeenCalledWith('/records/new'); + unmount(); + }); +}); diff --git a/src/components/records/record-attachments.tsx b/src/components/records/record-attachments.tsx index 5989fbe2..bb2c2124 100644 --- a/src/components/records/record-attachments.tsx +++ b/src/components/records/record-attachments.tsx @@ -126,7 +126,7 @@ export const RecordAttachments: React.FC = ({ recordId, setMessage(t('records.camera_denied')); return; } - const result = await ImagePicker.launchCameraAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: false, quality: 0.8, exif: false }); + const result = await ImagePicker.launchCameraAsync({ mediaTypes: ['images'], allowsEditing: false, quality: 0.8, exif: false }); const asset = result.canceled ? null : result.assets?.[0]; if (asset?.uri) { await stage(asset.uri, asset.fileName ?? `photo-${Date.now()}.jpg`, guessContentType(asset.fileName ?? '.jpg', asset.mimeType)); @@ -135,7 +135,7 @@ export const RecordAttachments: React.FC = ({ recordId, const pickFromLibrary = useCallback(async () => { const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, + mediaTypes: ['images'], allowsEditing: false, quality: 0.8, exif: false, diff --git a/src/components/records/records-quick-create.tsx b/src/components/records/records-quick-create.tsx index ef53f910..6e26f8b9 100644 --- a/src/components/records/records-quick-create.tsx +++ b/src/components/records/records-quick-create.tsx @@ -27,13 +27,16 @@ export const RecordsQuickCreate: React.FC = ({ context, const fetchCatalog = useRecordsStore((state) => state.fetchCatalog); const catalog = useRecordsStore((state) => state.catalog); + // Keyed on the context's values, not its identity: callers build the object inline, and a new + // reference on every render must not re-request the catalog for the same context. + const { CallId, UnitId, GroupId, CommandRole, ContactId } = context; useEffect(() => { if (flagStatus !== 'enabled') { return; } - setContext(context); + setContext({ CallId, UnitId, GroupId, CommandRole, ContactId }); void fetchCatalog(); - }, [flagStatus, context, setContext, fetchCatalog]); + }, [flagStatus, CallId, UnitId, GroupId, CommandRole, ContactId, setContext, fetchCatalog]); if (flagStatus !== 'enabled' || (catalog?.Definitions?.length ?? 0) === 0) { return null; diff --git a/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx b/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx index 1ca86703..023d0161 100644 --- a/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx +++ b/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx @@ -287,6 +287,24 @@ describe('ServerUrlBottomSheet', () => { }); }); + describe('Unreadable stored url', () => { + it('still opens the url field for editing when the stored url cannot be read', async () => { + const { logger } = jest.requireMock('@/lib/logging'); + mockGetUrl.mockRejectedValue(new Error('Storage unavailable')); + + const { unmount } = render(); + + await waitFor(() => { + expect(screen.queryByTestId('server-options-loading')).toBeNull(); + }); + + expect(screen.getByTestId('select-input').props.children).toBe('Custom'); + expect(screen.getByTestId('input-field').props.editable).toBe(true); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'Failed to read the current server URL' })); + unmount(); + }); + }); + describe('Changing servers', () => { it('fills the url field with the selected location ApiUrl', async () => { const { unmount } = render(); diff --git a/src/components/settings/server-url-bottom-sheet.tsx b/src/components/settings/server-url-bottom-sheet.tsx index 202b7240..82d5b25c 100644 --- a/src/components/settings/server-url-bottom-sheet.tsx +++ b/src/components/settings/server-url-bottom-sheet.tsx @@ -93,20 +93,31 @@ export function ServerUrlBottomSheet({ isOpen, onClose, onUrlChanged }: ServerUr setSelectedServer(matchingLocation?.Name ?? CUSTOM_SERVER_VALUE); } } catch (error) { + logger.error({ + message: 'Failed to load system config for server URLs', + context: { error }, + }); + // The current server could not supply its location list (e.g. an older // self-hosted install) — fall back to editing the URL by hand. - const currentUrl = await getUrl(); - if (isMounted) { setLocations([]); - setValue('url', normalizeCustomDisplayUrl(currentUrl)); setSelectedServer(CUSTOM_SERVER_VALUE); } - logger.error({ - message: 'Failed to load system config for server URLs', - context: { error }, - }); + // Read again on its own: the stored URL may be what failed above, and this + // sheet is the way out of a broken server URL, so it must still open for editing. + try { + const currentUrl = await getUrl(); + if (isMounted) { + setValue('url', normalizeCustomDisplayUrl(currentUrl)); + } + } catch (fallbackError) { + logger.error({ + message: 'Failed to read the current server URL', + context: { error: fallbackError }, + }); + } } finally { if (isMounted) { setIsLoadingServerOptions(false); diff --git a/src/lib/contacts/__tests__/format.test.ts b/src/lib/contacts/__tests__/format.test.ts index 2e3cd00d..248e8d9d 100644 --- a/src/lib/contacts/__tests__/format.test.ts +++ b/src/lib/contacts/__tests__/format.test.ts @@ -6,6 +6,10 @@ it('turns web-editor rich text into readable plain text', () => { expect(htmlToText(null)).toBe(''); }); +it('drops a script that only forms once an inner script is removed', () => { + expect(htmlToText('Gatexipt>alert(1) code')).toBe('Gate code'); +}); + it('parses coordinates and builds a maps link for an address or a point', () => { expect(parseCoordinates('39.7392, -104.9903')).toEqual({ latitude: 39.7392, longitude: -104.9903 }); expect(parseCoordinates('not a place')).toBeNull(); diff --git a/src/lib/contacts/format.ts b/src/lib/contacts/format.ts index 08a49510..797340e8 100644 --- a/src/lib/contacts/format.ts +++ b/src/lib/contacts/format.ts @@ -7,15 +7,25 @@ import { type ContactResultData } from '@/models/v4/contacts/contactResultData'; const ENTITIES: Record = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'", ' ': ' ' }; +/** Applies a removal until nothing changes, so markup nested inside markup (`…ipt>`) cannot reassemble. */ +const removeAll = (text: string, pattern: RegExp): string => { + let previous: string; + let current = text; + do { + previous = current; + current = current.replace(pattern, ''); + } while (current !== previous); + return current; +}; + /** Rich text from the web editor as plain readable text: line breaks kept, markup and scripts dropped. */ export const htmlToText = (html: string | null | undefined): string => { if (!html) return ''; - return html - .replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, '') + const withoutBlocks = removeAll(html, /<(script|style)[^>]*>[\s\S]*?<\/\1>/gi) .replace(//gi, '\n') .replace(/<\/(p|div|li|h[1-6]|tr)>/gi, '\n') - .replace(/]*>/gi, '• ') - .replace(/<[^>]+>/g, '') + .replace(/]*>/gi, '• '); + return removeAll(withoutBlocks, /<[^>]+>/g) .replace(/&(amp|lt|gt|quot|#39|apos|nbsp);/g, (entity) => ENTITIES[entity] ?? entity) .replace(/[ \t]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n') diff --git a/src/lib/records/__tests__/uploads.test.ts b/src/lib/records/__tests__/uploads.test.ts index f12e2637..cd7f3ff3 100644 --- a/src/lib/records/__tests__/uploads.test.ts +++ b/src/lib/records/__tests__/uploads.test.ts @@ -1,4 +1,4 @@ -import { runUpload } from '@/lib/records/uploads'; +import { hashFile, runUpload } from '@/lib/records/uploads'; // Shared conformance suite for resumable attachment upload (RMS plan RMS-1D). Identical in all four // app repositories: the server owns the session, so every adapter must resume from the server's own @@ -20,10 +20,15 @@ jest.mock('expo-file-system/legacy', () => ({ readAsStringAsync: jest.fn(), })); +// A real SHA-256 over whatever bytes the adapter hands over, so the test proves which bytes those are. jest.mock('expo-crypto', () => ({ CryptoDigestAlgorithm: { SHA256: 'SHA-256' }, CryptoEncoding: { HEX: 'hex' }, - digestStringAsync: jest.fn(), + digestStringAsync: jest.fn(async (_algorithm: string, data: string) => jest.requireActual('crypto').createHash('sha256').update(data, 'utf8').digest('hex')), + digest: jest.fn(async (_algorithm: string, data: Uint8Array) => { + const hash: Uint8Array = jest.requireActual('crypto').createHash('sha256').update(data).digest(); + return hash.buffer.slice(hash.byteOffset, hash.byteOffset + hash.byteLength); + }), })); jest.mock('@/lib/logging', () => ({ @@ -154,6 +159,24 @@ describe('Record attachment uploads', () => { expect(outcome.message).toBe('That file type is not accepted.'); }); + it('hashes the file bytes the server will assemble, not their base64 text', async () => { + fs.readAsStringAsync.mockResolvedValue('YWJj'); + + // SHA-256 of the three bytes "abc". + expect(await hashFile('file:///abc.txt')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); + }); + + it('stops instead of resending forever when the server count does not advance', async () => { + api.beginRecordUpload.mockResolvedValue(session(0)); + api.uploadRecordChunk.mockResolvedValueOnce(session(3)).mockResolvedValue(session(3)); + + const outcome = await runUpload(pending() as never); + + expect(outcome).toMatchObject({ ok: false, code: 'chunk_not_accepted', sentBytes: 3, uploadId: 'session-1' }); + expect(api.uploadRecordChunk).toHaveBeenCalledTimes(2); + expect(api.completeRecordUpload).not.toHaveBeenCalled(); + }); + it('stops between chunks when the person cancels', async () => { api.beginRecordUpload.mockResolvedValue(session(0)); api.uploadRecordChunk.mockResolvedValue(session(3)); diff --git a/src/lib/records/uploads.ts b/src/lib/records/uploads.ts index c5cc8f38..fef542c6 100644 --- a/src/lib/records/uploads.ts +++ b/src/lib/records/uploads.ts @@ -1,3 +1,4 @@ +import { Buffer } from 'buffer'; import * as Crypto from 'expo-crypto'; import * as FileSystem from 'expo-file-system/legacy'; @@ -65,10 +66,15 @@ const problemMessage = (error: unknown): string => { return response?.data?.title ?? (error instanceof Error ? error.message : 'Upload failed'); }; -/** SHA-256 of the file, hex lower-case, computed without holding the whole file as a string twice. */ +/** + * SHA-256 of the file's bytes, hex lower-case. The server hashes the assembled bytes, so the digest is + * taken over the decoded file — hashing the base64 text instead would never match and every upload + * would be refused at completion. + */ export const hashFile = async (fileUri: string): Promise => { const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 }); - return (await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, base64, { encoding: Crypto.CryptoEncoding.HEX })).toLowerCase(); + const digest = await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, Buffer.from(base64, 'base64')); + return Buffer.from(digest).toString('hex'); }; export const fileSize = async (fileUri: string): Promise => { @@ -162,6 +168,10 @@ export const runUpload = async (pending: PendingUpload, options: UploadOptions = if (!updated) { return { ok: false, code: 'chunk_failed', sentBytes: sent, uploadId: session.UploadId }; } + if (!(updated.ReceivedBytes > sent)) { + // The server kept nothing of this chunk; sending it again would loop forever, so stop and say so. + return { ok: false, code: 'chunk_not_accepted', sentBytes: sent, uploadId: session.UploadId }; + } // Trust the server's new count rather than adding locally, so a partially accepted chunk // cannot leave the client and the server disagreeing about where the file is. sent = updated.ReceivedBytes; diff --git a/src/services/__tests__/app-reset.service.test.ts b/src/services/__tests__/app-reset.service.test.ts index 10e2994f..5c151b39 100644 --- a/src/services/__tests__/app-reset.service.test.ts +++ b/src/services/__tests__/app-reset.service.test.ts @@ -234,6 +234,30 @@ jest.mock('@/stores/chat/store', () => ({ }, })); +jest.mock('@/stores/records/store', () => ({ + useRecordsStore: { + getState: jest.fn(), + }, +})); + +jest.mock('@/stores/records/deployments-store', () => ({ + useDeploymentsStore: { + getState: jest.fn(), + }, +})); + +jest.mock('@/stores/contacts/preplan-store', () => ({ + useContactPreplanStore: { + getState: jest.fn(), + }, +})); + +jest.mock('@/stores/calls/site-info-store', () => ({ + useSiteInfoStore: { + getState: jest.fn(), + }, +})); + jest.mock('@/stores/signalr/signalr-store', () => ({ useSignalRStore: { setState: jest.fn(), @@ -280,6 +304,10 @@ const mockLiveKitDisconnect = jest.fn().mockResolvedValue(undefined); const mockWeatherAlertsReset = jest.fn(); const mockCheckInTimerReset = jest.fn(); const mockChatReset = jest.fn(); +const mockRecordsReset = jest.fn(); +const mockDeploymentsReset = jest.fn(); +const mockPreplanReset = jest.fn(); +const mockSiteInfoReset = jest.fn(); describe('app-reset.service', () => { beforeEach(() => { @@ -330,6 +358,11 @@ describe('app-reset.service', () => { reset: mockChatReset, }); + jest.requireMock('@/stores/records/store').useRecordsStore.getState.mockReturnValue({ reset: mockRecordsReset }); + jest.requireMock('@/stores/records/deployments-store').useDeploymentsStore.getState.mockReturnValue({ reset: mockDeploymentsReset }); + jest.requireMock('@/stores/contacts/preplan-store').useContactPreplanStore.getState.mockReturnValue({ reset: mockPreplanReset }); + jest.requireMock('@/stores/calls/site-info-store').useSiteInfoStore.getState.mockReturnValue({ reset: mockSiteInfoReset }); + locationService.stopLocationUpdates.mockResolvedValue(undefined); signalRService.disconnectAll.mockResolvedValue(undefined); }); @@ -625,6 +658,11 @@ describe('app-reset.service', () => { expect(mockWeatherAlertsReset).toHaveBeenCalled(); expect(mockCheckInTimerReset).toHaveBeenCalled(); expect(mockChatReset).toHaveBeenCalled(); + // Unsent Field Records work and cached protected contact data must not reach the next sign-in. + expect(mockRecordsReset).toHaveBeenCalled(); + expect(mockDeploymentsReset).toHaveBeenCalled(); + expect(mockPreplanReset).toHaveBeenCalled(); + expect(mockSiteInfoReset).toHaveBeenCalled(); }); it('should disconnect from LiveKit room if connected', async () => { diff --git a/src/services/app-reset.service.ts b/src/services/app-reset.service.ts index de00bdf3..57e7626b 100644 --- a/src/services/app-reset.service.ts +++ b/src/services/app-reset.service.ts @@ -20,9 +20,11 @@ import { useCoreStore } from '@/stores/app/core-store'; import { useLiveKitStore } from '@/stores/app/livekit-store'; import { useLoadingStore } from '@/stores/app/loading-store'; import { useLocationStore } from '@/stores/app/location-store'; +import { useSiteInfoStore } from '@/stores/calls/site-info-store'; import { useCallsStore } from '@/stores/calls/store'; import { useChatStore } from '@/stores/chat/store'; import { useCheckInTimerStore } from '@/stores/check-in-timers/store'; +import { useContactPreplanStore } from '@/stores/contacts/preplan-store'; import { useContactsStore } from '@/stores/contacts/store'; import { useDispatchStore } from '@/stores/dispatch/store'; import { featureFlagsStore } from '@/stores/feature-flags/store'; @@ -32,6 +34,8 @@ import { useOfflineQueueStore } from '@/stores/offline-queue/store'; import { usePoisStore } from '@/stores/pois/store'; import { useProtocolsStore } from '@/stores/protocols/store'; import { usePushNotificationModalStore } from '@/stores/push-notification/store'; +import { useDeploymentsStore } from '@/stores/records/deployments-store'; +import { useRecordsStore } from '@/stores/records/store'; import { useRolesStore } from '@/stores/roles/store'; import { useRoutesStore } from '@/stores/routes/store'; import { securityStore } from '@/stores/security/store'; @@ -338,6 +342,16 @@ export const resetAllStores = async (): Promise => { // Chat store — clears channels/messages/outbox and stops typing/outbox timers. useChatStore.getState().reset(); + + // Field Records — clearPersistedStorage() wipes the persisted drafts and uploads, but the in-memory + // copies would otherwise be written back on the next change and pushed under the next user's sign-in. + useRecordsStore.getState().reset(); + useDeploymentsStore.getState().reset(); + + // Contact pre-plans, site files and call site info are cached protected data; the next user + // must fetch their own view rather than see what the previous grant revealed. + useContactPreplanStore.getState().reset(); + useSiteInfoStore.getState().reset(); }; /** diff --git a/src/stores/app/core-store.ts b/src/stores/app/core-store.ts index 0f744697..d824f273 100644 --- a/src/stores/app/core-store.ts +++ b/src/stores/app/core-store.ts @@ -56,7 +56,7 @@ interface CoreState { isInitializing: boolean; error: string | null; init: () => Promise; - setActiveUnit: (unitId: string) => void; + setActiveUnit: (unitId: string) => Promise; setActiveUnitWithFetch: (unitId: string) => Promise; refreshActiveUnitStatus: (unitId: string) => Promise; setActiveCall: (callId: string | null) => Promise; diff --git a/src/stores/calls/__tests__/site-info-store.test.ts b/src/stores/calls/__tests__/site-info-store.test.ts index bf16b582..1399d5e2 100644 --- a/src/stores/calls/__tests__/site-info-store.test.ts +++ b/src/stores/calls/__tests__/site-info-store.test.ts @@ -117,6 +117,32 @@ describe('useSiteInfoStore', () => { expect(useSiteInfoStore.getState().siteInfo?.CallId).toBe('43'); }); + it('keeps the newest answer when the same call is re-fetched after a grant change', async () => { + const beforeGrant = deferred(); + mockGetCallSiteInfo.mockImplementationOnce(() => beforeGrant.promise); + mockGetCallSiteInfo.mockResolvedValueOnce({ Data: { ...makeSiteInfo('42'), IsProtected: true } } as any); + + const firstFetch = useSiteInfoStore.getState().fetchSiteInfo('42'); + await useSiteInfoStore.getState().fetchSiteInfo('42'); + + beforeGrant.resolve({ Data: makeSiteInfo('42') }); + await firstFetch; + + expect(useSiteInfoStore.getState().siteInfo?.IsProtected).toBe(true); + }); + + it('ignores a response that lands after a reset', async () => { + const pending = deferred(); + mockGetCallSiteInfo.mockImplementationOnce(() => pending.promise); + + const fetch = useSiteInfoStore.getState().fetchSiteInfo('42'); + useSiteInfoStore.getState().reset(); + pending.resolve({ Data: makeSiteInfo('42') }); + await fetch; + + expect(useSiteInfoStore.getState().siteInfo).toBeNull(); + }); + it('reset clears everything', async () => { mockGetCallSiteInfo.mockResolvedValue({ Data: makeSiteInfo('42') } as any); await useSiteInfoStore.getState().fetchSiteInfo('42'); diff --git a/src/stores/calls/site-info-store.ts b/src/stores/calls/site-info-store.ts index ce7354f0..7cbeae26 100644 --- a/src/stores/calls/site-info-store.ts +++ b/src/stores/calls/site-info-store.ts @@ -14,6 +14,11 @@ interface SiteInfoState { reset: () => void; } +// Bumped by every fetch and every reset. The tab re-fetches the same call when the protected-data grant +// changes, so the call id alone cannot tell a stale answer apart: a slow pre-grant response could put +// REDACTED values back, or a slow granted one could keep revealed values on screen after the grant ends. +let requestSeq = 0; + /** * Site Info tab state (Contacts plan Phase A): the pre-plans, hazards, alert notes and files of the * contacts linked to the call being viewed. One call at a time; the tab re-fetches after a step-up so @@ -26,15 +31,16 @@ export const useSiteInfoStore = create((set, get) => ({ error: null, fetchSiteInfo: async (callId: string) => { + const seq = ++requestSeq; set({ isLoading: true, error: null, callId }); try { const result = await getCallSiteInfo(callId); - if (get().callId !== callId) { - return; // stale response — a different call is being viewed now + if (seq !== requestSeq || get().callId !== callId) { + return; // stale response — a newer request (or a different call) is in charge now } set({ siteInfo: result.Data ?? null, isLoading: false }); } catch (error) { - if (get().callId !== callId) { + if (seq !== requestSeq || get().callId !== callId) { return; } logger.error({ @@ -49,5 +55,8 @@ export const useSiteInfoStore = create((set, get) => ({ } }, - reset: () => set({ callId: null, siteInfo: null, isLoading: false, error: null }), + reset: () => { + requestSeq += 1; + set({ callId: null, siteInfo: null, isLoading: false, error: null }); + }, })); diff --git a/src/stores/checklists/__tests__/store.test.ts b/src/stores/checklists/__tests__/store.test.ts index aea09e36..63440f3d 100644 --- a/src/stores/checklists/__tests__/store.test.ts +++ b/src/stores/checklists/__tests__/store.test.ts @@ -48,6 +48,23 @@ it('keeps values in the dedicated vault and only references in the ordinary offl useChecklistsStore.getState().close(); await useChecklistsStore.getState().openDraft(run().Id); expect(useChecklistsStore.getState().active?.input.Note).toBe('SYNTHETIC PHI'); }); +it('queues a completion even after an earlier draft write failed', async () => { + await useChecklistsStore.getState().load(); await useChecklistsStore.getState().startDefinition(definition(), run().Target); + jest.mocked(vaultWrite).mockRejectedValueOnce(new Error('disk')); + await expect(useChecklistsStore.getState().update({ Revision: 0, Answers: [], Note: 'first' })).rejects.toThrow('disk'); + await useChecklistsStore.getState().queue(true); + expect(useOfflineQueueStore.getState().addEvent).toHaveBeenCalledWith('checklist_completion', { scope, id: run().Id }, 5); + expect((await vaultRead>(scope, `draft:${run().Id}`))?.queued).toBe(true); +}); +it('writes a burst of edits as one vault write of the newest snapshot', async () => { + await useChecklistsStore.getState().load(); await useChecklistsStore.getState().startDefinition(definition(), run().Target); + jest.mocked(vaultWrite).mockClear(); + const typed = ['N', 'No', 'Not', 'Note'].map((Note) => useChecklistsStore.getState().update({ Revision: 0, Answers: [], Note })); + await Promise.all(typed); + expect(jest.mocked(vaultWrite).mock.calls.filter(([, name]) => name === `draft:${run().Id}`)).toHaveLength(1); + expect((await vaultRead>(scope, `draft:${run().Id}`))?.input.Note).toBe('Note'); + expect(useChecklistsStore.getState().active?.input.Note).toBe('Note'); +}); it('previews occurrences without starting server work and reopens prepared data offline', async () => { await useChecklistsStore.getState().load(); server.previewChecklistOccurrence.mockResolvedValue({ ...run(), Revision: 0, IsPreview: true, OccurrenceId: 'occurrence', Input: { Revision: 0, Answers: [] } }); diff --git a/src/stores/checklists/store.ts b/src/stores/checklists/store.ts index c345eacb..6fc07146 100644 --- a/src/stores/checklists/store.ts +++ b/src/stores/checklists/store.ts @@ -59,14 +59,33 @@ const assertOpen = (scope: string) => { if (!current(scope) || !state.access?.Enabled) throw new Error('denied'); if (AppState.currentState !== 'active' || (state.access.IsProtected && !dataProtectionStore.getState().isStepUpActive())) throw new Error('locked'); }; +// A draft is a whole snapshot, so a write still waiting at the end of the line can take a newer snapshot +// of the same draft instead of queueing another: typing a note costs one encrypted vault write at a time, +// not one per keystroke. Only the tail is merged, so the order against every other write is unchanged. +interface WaitingDraftWrite { + key: string; + copy: ChecklistDraft; + job: Promise; +} +let waitingDraft: WaitingDraftWrite | null = null; const persistDraft = (draft: ChecklistDraft): Promise => { - const copy: ChecklistDraft = JSON.parse(JSON.stringify(draft)); - return serializeWrite(async () => { + const key = `${draft.scope}:${draft.id}`; + const snapshot: ChecklistDraft = JSON.parse(JSON.stringify(draft)); + if (waitingDraft?.key === key && writes === waitingDraft.job) { + waitingDraft.copy = snapshot; + return waitingDraft.job; + } + const waiting: WaitingDraftWrite = { key, copy: snapshot, job: writes }; + waiting.job = serializeWrite(async () => { + if (waitingDraft === waiting) waitingDraft = null; + const { copy } = waiting; const ids = (await vaultRead(copy.scope, 'index')) ?? []; if (!ids.includes(copy.id) && ids.length >= 50) throw new Error('storage_full'); await vaultWrite(copy.scope, `draft:${copy.id}`, copy); if (!ids.includes(copy.id)) await vaultWrite(copy.scope, 'index', [...ids, copy.id]); }); + waitingDraft = waiting; + return waiting.job; }; // Keep a bounded, value-free receipt before erasing acknowledged content. A queue retry after // a process exit can consume this receipt without recreating the completed run. @@ -255,7 +274,8 @@ export const useChecklistsStore = create()((set, get) => ({ queue: async (submit) => { const draft = get().active; if (!draft || !draft.run.CanEdit) throw new Error('denied'); - await writes; + // An earlier write's failure was already reported to its own caller; this one stages its own copy. + await writes.catch(() => undefined); const queued = { ...draft, queued: true, submit, input: { ...draft.input, ClientCompletedOn: submit ? (draft.input.ClientCompletedOn ?? new Date().toISOString()) : draft.input.ClientCompletedOn } }; await stage(queued); enqueue(draft.scope, draft.id); @@ -312,7 +332,7 @@ export const flushChecklistDraft = (scope: string, id: string): Promise => if (scope !== checklistScope()) throw new Error('denied'); const verified = await readAccess(); if (verified !== scope) throw new Error('denied'); - await writes; + await writes.catch(() => undefined); const draft = await vaultRead(scope, `draft:${id}`); assertOpen(scope); if (!draft) { diff --git a/src/stores/operations/__tests__/store.test.ts b/src/stores/operations/__tests__/store.test.ts index 12a71608..5c8a52ff 100644 --- a/src/stores/operations/__tests__/store.test.ts +++ b/src/stores/operations/__tests__/store.test.ts @@ -129,6 +129,24 @@ it('opens the crew report for the scope on demand, refuses invalid entries local expect(useOperationsStore.getState().report?.Status).toBe(2); }); +it('drops a deployment that answers after the person left it or opened another', async () => { + let releaseFirst!: (value: never) => void; + server.getDeployment.mockReturnValueOnce(new Promise((resolve) => (releaseFirst = resolve)) as never); + const leaving = useOperationsStore.getState().open('dep-1'); + useOperationsStore.getState().close(); + releaseFirst(deployment as never); + await leaving; + expect(useOperationsStore.getState().deployment).toBeNull(); + + let releaseSlow!: (value: never) => void; + server.getDeployment.mockReturnValueOnce(new Promise((resolve) => (releaseSlow = resolve)) as never).mockResolvedValueOnce({ ...deployment, Id: 'dep-2' } as never); + const slow = useOperationsStore.getState().open('dep-1'); + await useOperationsStore.getState().open('dep-2'); + releaseSlow(deployment as never); + await slow; + expect(useOperationsStore.getState().deployment?.Id).toBe('dep-2'); +}); + it('keeps validation refusals from the server on the report and switches scope without leaking entries', async () => { server.getTimeReports.mockResolvedValue([report(0, [{ Id: 'e-1', SubjectType: 0, DeploymentPersonnelId: 'dp-1', EntryType: 0, StartLocal: '2026-09-19T08:00', EndLocal: '2026-09-19T18:00' }])] as never); await useOperationsStore.getState().open('dep-1'); diff --git a/src/stores/operations/store.ts b/src/stores/operations/store.ts index ccfc4ca7..67548317 100644 --- a/src/stores/operations/store.ts +++ b/src/stores/operations/store.ts @@ -137,6 +137,10 @@ const currentIdentity = (): string | null => { return userId && departmentId ? `${userId}:${departmentId}` : null; }; +// Bumped by every open, close and identity change, so a deployment that answers after the person has +// left it (or opened another one) is dropped instead of repopulating the store. +let openGeneration = 0; + const copyEntries = (report: TimeReport | null) => (report ? report.Entries.map((entry) => ({ ...entry })) : []); export const useOperationsStore = create()((set, get) => { @@ -165,7 +169,10 @@ export const useOperationsStore = create()((set, get) => { ...initial, loadAccess: async () => { const identity = currentIdentity(); - if (get().identity !== identity) set({ ...initial, identity }); + if (get().identity !== identity) { + openGeneration += 1; + set({ ...initial, identity }); + } await settle(async () => { const access = await getDeploymentAccess(); // Usage and MARS access are optional add-ons; a failure there must not hide the deployments. @@ -183,8 +190,10 @@ export const useOperationsStore = create()((set, get) => { }); }, open: async (id) => { + const generation = ++openGeneration; await settle(async () => { const [deployment, reports] = await Promise.all([getDeployment(id), getTimeReports(id)]); + if (generation !== openGeneration) return; set({ ...closed, deployment, reports }); }); }, @@ -306,6 +315,9 @@ export const useOperationsStore = create()((set, get) => { }); return validated === true; }, - close: () => set({ ...closed, error: null }), + close: () => { + openGeneration += 1; + set({ ...closed, error: null }); + }, }; }); diff --git a/src/stores/records/__tests__/deployments-store.test.ts b/src/stores/records/__tests__/deployments-store.test.ts index 57f3ddd6..eb13359e 100644 --- a/src/stores/records/__tests__/deployments-store.test.ts +++ b/src/stores/records/__tests__/deployments-store.test.ts @@ -124,6 +124,23 @@ describe('Deployments store conformance', () => { expect(useDeploymentsStore.getState().error).toBe('not_found'); }); + it('keeps a deployment through a transient failure but drops it once access is refused', async () => { + useDeploymentsStore.setState({ deployments: [deployment('o1', '2026-09-01T00:00:00Z'), deployment('o2', '2026-09-02T00:00:00Z')] }); + api.getRecordDeployment.mockRejectedValueOnce(new Error('offline')).mockRejectedValueOnce(forbidden()); + + const offline = await useDeploymentsStore.getState().fetchDeployment('o1'); + expect(offline?.OrderId).toBe('o1'); + expect(useDeploymentsStore.getState().error).toBe('offline'); + + const refused = await useDeploymentsStore.getState().fetchDeployment('o1'); + expect(refused).toBeNull(); + expect(useDeploymentsStore.getState().deployments.map((d) => d.OrderId)).toEqual(['o2']); + expect(useDeploymentsStore.getState().error).toBe('forbidden'); + + api.getRecordDeployments.mockRejectedValueOnce(forbidden()); + expect(await useDeploymentsStore.getState().fetchDeployments()).toEqual([]); + }); + it('replaces only one connector slice of the reconciliation when asked for that connector', async () => { useDeploymentsStore.setState({ reconciliation: [ diff --git a/src/stores/records/__tests__/store.test.ts b/src/stores/records/__tests__/store.test.ts index 54136c21..3236a8a2 100644 --- a/src/stores/records/__tests__/store.test.ts +++ b/src/stores/records/__tests__/store.test.ts @@ -137,6 +137,25 @@ describe('Field Records store conformance', () => { expect(useRecordsStore.getState().pendingDrafts).toEqual({}); }); + it('sends a protected definition online without ever keeping it on the device', async () => { + useRecordsStore.setState({ catalog: { ContractVersion: 'field-catalog.v1', OriginClient: 'Unit', Ok: true, Reasons: [], ContextVerified: true, Definitions: [goldenCatalogEntry({ RequiresProtectedGrant: true })], Exclusions: [], ServerTimestampMs: 0 } }); + const draft = { clientRecordId: 'draft-p', recordId: null, definitionKey: 'shift-log', definitionVersion: 3, name: 'Shift log', values: [], updatedOn: '2026-09-06T00:00:00Z' }; + recordsApi.createRecordDraft.mockRejectedValueOnce({ response: { status: 500, data: { title: 'Unavailable' } } }).mockResolvedValueOnce({ Data: { RecordId: 'server-p', DefinitionVersion: 3, State: RmsRecordState.Draft, RowVersion: 1 } }); + fieldApi.syncFieldRecords.mockResolvedValue({ Data: { Ok: true, ResetRequired: false, ScopeStamp: 'scope-1', ServerTimestampMs: 10, Records: [], Tombstones: [], Drafts: [], Assignments: [] } }); + + useRecordsStore.getState().stageDraft(draft); + const failed = await useRecordsStore.getState().pushDraft('draft-p', draft); + + expect(failed).toMatchObject({ ok: false, error: 'Unavailable' }); + expect(useRecordsStore.getState().pendingDrafts).toEqual({}); + + const sent = await useRecordsStore.getState().pushDraft('draft-p', draft); + + expect(sent).toMatchObject({ ok: true, recordId: 'server-p' }); + expect(recordsApi.createRecordDraft).toHaveBeenLastCalledWith(expect.objectContaining({ DefinitionKey: 'shift-log', IdempotencyKey: 'draft-p' })); + expect(useRecordsStore.getState().pendingDrafts).toEqual({}); + }); + it('keeps a conflicted draft and never replays it in the batch push', async () => { useRecordsStore.setState({ catalog: { ContractVersion: 'field-catalog.v1', OriginClient: 'Unit', Ok: true, Reasons: [], ContextVerified: true, Definitions: [goldenCatalogEntry()], Exclusions: [], ServerTimestampMs: 0 } }); recordsApi.createRecordDraft.mockRejectedValue({ response: { status: 409, data: { type: 'record_concurrency', title: 'Changed' } } }); diff --git a/src/stores/records/deployments-store.ts b/src/stores/records/deployments-store.ts index f8ad5e03..9e2e7bd1 100644 --- a/src/stores/records/deployments-store.ts +++ b/src/stores/records/deployments-store.ts @@ -43,8 +43,16 @@ export interface DeploymentsState { reset: () => void; } +const statusOf = (error: unknown): number | undefined => (error as { response?: { status?: number } })?.response?.status; + +/** A refusal or a missing row is an answer about access, not a transient failure; nothing cached outlives it. */ +const isAccessAnswer = (error: unknown): boolean => { + const status = statusOf(error); + return status === 403 || status === 404; +}; + const messageOf = (error: unknown, fallback: string): string => { - const status = (error as { response?: { status?: number } })?.response?.status; + const status = statusOf(error); if (status === 403) { return 'forbidden'; } @@ -78,7 +86,8 @@ export const useDeploymentsStore = create()( return deployments; } catch (error) { logger.error({ message: 'Deployments fetch failed', context: { error } }); - set({ isLoading: false, error: messageOf(error, 'load_failed') }); + // Offline keeps what was last read; a refusal drops it rather than showing it past revoked access. + set({ isLoading: false, error: messageOf(error, 'load_failed'), ...(isAccessAnswer(error) ? { deployments: [] } : {}) }); return get().deployments; } }, @@ -97,6 +106,11 @@ export const useDeploymentsStore = create()( return deployment; } catch (error) { logger.error({ message: 'Deployment fetch failed', context: { error, orderId } }); + if (isAccessAnswer(error)) { + // Refused or gone: dropped the same way as an empty answer, never shown from the last fetch. + set({ deployments: get().deployments.filter((existing) => existing.OrderId !== orderId), isLoading: false, error: messageOf(error, 'load_failed') }); + return null; + } set({ isLoading: false, error: messageOf(error, 'load_failed') }); return get().deployments.find((existing) => existing.OrderId === orderId) ?? null; } diff --git a/src/stores/records/store.ts b/src/stores/records/store.ts index ae32fbde..96b50e30 100644 --- a/src/stores/records/store.ts +++ b/src/stores/records/store.ts @@ -116,7 +116,8 @@ interface RecordsState { stageDraft: (draft: PendingRecordDraft) => void; discardDraft: (clientRecordId: string) => void; - pushDraft: (clientRecordId: string) => Promise<{ ok: boolean; recordId?: string; conflict?: FieldRecordConflictKind; error?: string }>; + /** Sends a staged draft, or `draft` itself when it was not staged (a definition that seals values never is). */ + pushDraft: (clientRecordId: string, draft?: PendingRecordDraft) => Promise<{ ok: boolean; recordId?: string; conflict?: FieldRecordConflictKind; error?: string }>; pushAllDrafts: () => Promise; submitForReview: (recordId: string, rowVersion: number) => Promise<{ ok: boolean; error?: string }>; finalize: (recordId: string, rowVersion: number, attested: boolean) => Promise<{ ok: boolean; error?: string }>; @@ -168,6 +169,9 @@ const conflictFrom = (error: unknown): FieldRecordConflictKind | undefined => { return undefined; }; +/** An unknown definition (catalog not loaded) is kept; only one known to seal values is refused. */ +const mayKeepOnDevice = (entry: FieldRecordCatalogEntry | null): boolean => !entry || canAuthorOffline(entry); + const messageFrom = (error: unknown): string => { const response = (error as { response?: { data?: { title?: string } } })?.response; return response?.data?.title ?? (error instanceof Error ? error.message : 'Request failed'); @@ -335,8 +339,7 @@ export const useRecordsStore = create()( }, stageDraft: (draft) => { - const entry = get().entryFor(draft.definitionKey, draft.definitionVersion); - if (entry && !canAuthorOffline(entry)) { + if (!mayKeepOnDevice(get().entryFor(draft.definitionKey, draft.definitionVersion))) { // A definition that seals values never leaves plaintext on the device, so it is not staged. logger.info({ message: 'Draft not staged offline: definition requires a live protected-data grant', context: { definitionKey: draft.definitionKey } }); return; @@ -350,8 +353,8 @@ export const useRecordsStore = create()( set({ pendingDrafts: pending }); }, - pushDraft: async (clientRecordId) => { - const draft = get().pendingDrafts[clientRecordId]; + pushDraft: async (clientRecordId, supplied) => { + const draft = supplied ?? get().pendingDrafts[clientRecordId]; if (!draft) { return { ok: false, error: 'not_found' }; } @@ -384,13 +387,16 @@ export const useRecordsStore = create()( return { ok: true, recordId }; } catch (error) { const conflict = conflictFrom(error); - // Never replayed silently: the draft is kept and flagged so a person decides what happens. - set({ - pendingDrafts: { - ...get().pendingDrafts, - [clientRecordId]: { ...draft, lastError: messageFrom(error), conflict: conflict ?? null }, - }, - }); + // Never replayed silently: the draft is kept and flagged so a person decides what happens — + // unless its definition seals values, which are never left on the device. + if (mayKeepOnDevice(get().entryFor(draft.definitionKey, draft.definitionVersion))) { + set({ + pendingDrafts: { + ...get().pendingDrafts, + [clientRecordId]: { ...draft, lastError: messageFrom(error), conflict: conflict ?? null }, + }, + }); + } logger.error({ message: 'Record draft push failed', context: { error, clientRecordId, conflict } }); get().report({ EventType: 'draft_saved', Outcome: conflict ?? 'failed', DefinitionKey: draft.definitionKey, DefinitionVersion: draft.definitionVersion }); if (conflict) { diff --git a/src/translations/en.json b/src/translations/en.json index cea38620..790fe8b6 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -474,7 +474,7 @@ "Answered": "Answered", "AssetUnavailable": "Serialized equipment is unavailable.", "Assignment": "Assigned to", - "AssignmentAutomatic": "Automatic routing", + "AssignmentAutomatic": "Automatic assignment", "AssignmentPermission": "You are not assigned to perform this check.", "AssignmentType1": "Person", "AssignmentType2": "Personnel role", diff --git a/src/utils/__tests__/file-name.test.ts b/src/utils/__tests__/file-name.test.ts new file mode 100644 index 00000000..0e20cbba --- /dev/null +++ b/src/utils/__tests__/file-name.test.ts @@ -0,0 +1,25 @@ +import { safeFileName } from '../file-name'; + +describe('safeFileName', () => { + it('keeps an ordinary file name as it is', () => { + expect(safeFileName('Site plan (rev 2).pdf', 'fallback')).toBe('Site plan (rev 2).pdf'); + }); + + it('keeps only the last segment of a name that carries a path', () => { + expect(safeFileName('../../Library/Preferences/app.plist', 'fallback')).toBe('app.plist'); + expect(safeFileName('..\\..\\evil.bin', 'fallback')).toBe('evil.bin'); + expect(safeFileName('/etc/hosts', 'fallback')).toBe('hosts'); + }); + + it('neutralizes characters that change how a file uri is read', () => { + expect(safeFileName('..%2F..%2Fsecret.txt', 'fallback')).toBe('.._2F.._2Fsecret.txt'); + expect(safeFileName('report#1?.pdf', 'fallback')).toBe('report_1_.pdf'); + }); + + it('falls back when nothing usable is left', () => { + expect(safeFileName('..', 'fallback')).toBe('fallback'); + expect(safeFileName('dir/', 'fallback')).toBe('fallback'); + expect(safeFileName(' ', 'fallback')).toBe('fallback'); + expect(safeFileName(null, 'fallback')).toBe('fallback'); + }); +}); diff --git a/src/utils/file-name.ts b/src/utils/file-name.ts new file mode 100644 index 00000000..f8c16ed6 --- /dev/null +++ b/src/utils/file-name.ts @@ -0,0 +1,14 @@ +// Characters that separate path segments or change how a file URI is read (`%` escapes, `#` and `?` +// start a fragment or query), plus the ones file systems refuse and control characters. +// eslint-disable-next-line no-control-regex +const UNSAFE_CHARACTERS = /[\\/:*?"<>|#%\u0000-\u001f\u007f]/g; + +/** + * A server-supplied file name reduced to one safe path segment, so writing it under an app directory + * can never land outside that directory. Anything left empty or made only of dots becomes `fallback`. + */ +export const safeFileName = (name: string | null | undefined, fallback: string): string => { + const lastSegment = (name ?? '').split(/[\\/]/).pop() ?? ''; + const cleaned = lastSegment.replace(UNSAFE_CHARACTERS, '_').trim(); + return /^\.*$/.test(cleaned) ? fallback : cleaned; +}; From e7942554d7cfce9e5496daffe221266c54c8dfcb Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 25 Sep 2026 23:23:55 -0700 Subject: [PATCH 5/8] RU-T53 PR #287 fixes --- src/lib/records/__tests__/uploads.test.ts | 19 ++++ src/lib/records/uploads.ts | 19 ++-- src/stores/app/__tests__/core-store.test.ts | 31 +++++++ src/stores/app/core-store.ts | 17 +++- .../contacts/__tests__/preplan-store.test.ts | 69 ++++++++++++++ src/stores/contacts/preplan-store.ts | 15 ++- src/stores/operations/__tests__/store.test.ts | 15 +++ src/stores/operations/store.ts | 2 + .../__tests__/deployments-store.test.ts | 13 +++ src/stores/records/__tests__/store.test.ts | 93 +++++++++++++++++++ src/stores/records/deployments-store.ts | 61 ++++++++---- src/stores/records/store.ts | 66 ++++++++++--- 12 files changed, 374 insertions(+), 46 deletions(-) create mode 100644 src/stores/contacts/__tests__/preplan-store.test.ts diff --git a/src/lib/records/__tests__/uploads.test.ts b/src/lib/records/__tests__/uploads.test.ts index cd7f3ff3..d7aec28a 100644 --- a/src/lib/records/__tests__/uploads.test.ts +++ b/src/lib/records/__tests__/uploads.test.ts @@ -113,6 +113,25 @@ describe('Record attachment uploads', () => { expect(chunks.map((chunk) => chunk.Data).join('')).toBe(tenBytesBase64); }); + it('resumes from a server count that is not a whole base64 group without shifting the bytes', async () => { + // Nine distinct bytes, so a chunk that starts one byte off would send the wrong ones. + const file = Buffer.from([10, 11, 12, 13, 14, 15, 16, 17, 18]); + fs.readAsStringAsync.mockResolvedValue(file.toString('base64')); + api.getRecordUpload.mockResolvedValue(session(4)); + api.uploadRecordChunk.mockResolvedValueOnce(session(7)).mockResolvedValueOnce(session(9)); + api.completeRecordUpload.mockResolvedValue({ Data: { AttachmentId: 'a5' } }); + + const outcome = await runUpload(pending({ uploadId: 'session-1' }) as never); + + expect(outcome.ok).toBe(true); + const chunks: { Offset: number; Data: string }[] = api.uploadRecordChunk.mock.calls.map((call: unknown[]) => call[0] as { Offset: number; Data: string }); + expect(chunks.map((chunk) => chunk.Offset)).toEqual([4, 7]); + expect(chunks.map((chunk) => [...Buffer.from(chunk.Data, 'base64')])).toEqual([ + [14, 15, 16], + [17, 18], + ]); + }); + it('resumes from the count the server reports, not the one the device remembers', async () => { api.getRecordUpload.mockResolvedValue(session(6)); api.uploadRecordChunk.mockResolvedValue(session(9)); diff --git a/src/lib/records/uploads.ts b/src/lib/records/uploads.ts index fef542c6..0571caea 100644 --- a/src/lib/records/uploads.ts +++ b/src/lib/records/uploads.ts @@ -83,18 +83,13 @@ export const fileSize = async (fileUri: string): Promise => { }; /** - * The server hashes the assembled bytes, so the hash we declare has to be of the same bytes. Reading - * base64 once and slicing it keeps the chunk boundaries aligned: base64 encodes 3 bytes as 4 chars, - * so a chunk size that is a multiple of 3 slices cleanly without re-encoding anything. + * One chunk of the file, base64 encoded for the wire. The bytes are sliced before they are encoded, so + * the chunk starts exactly at the server's count even when that count is not a whole 3-byte base64 + * group — as after a partially accepted chunk — and the assembled bytes still match the declared hash. */ -const chunkOf = (base64: string, offsetBytes: number, chunkBytes: number): string => { - const start = (offsetBytes / 3) * 4; - // Only the final chunk can be short of a multiple of 3; its trailing 1 or 2 bytes still occupy a - // full padded 4-character group, and slice() would otherwise truncate the fraction and drop them. - const length = Math.ceil(chunkBytes / 3) * 4; - return base64.slice(start, start + length); -}; +const chunkOf = (bytes: Uint8Array, offsetBytes: number, chunkBytes: number): string => Buffer.from(bytes.subarray(offsetBytes, offsetBytes + chunkBytes)).toString('base64'); +// Whole 3-byte groups keep every full chunk free of base64 padding. const alignChunkSize = (chunkSize: number): number => { const safe = Math.max(3, Math.min(chunkSize || 0, 3 * 1024 * 1024)); return safe - (safe % 3); @@ -153,7 +148,7 @@ export const runUpload = async (pending: PendingUpload, options: UploadOptions = } const chunkSize = alignChunkSize(session.ChunkSize); - const base64 = await FileSystem.readAsStringAsync(pending.fileUri, { encoding: FileSystem.EncodingType.Base64 }); + const bytes = Buffer.from(await FileSystem.readAsStringAsync(pending.fileUri, { encoding: FileSystem.EncodingType.Base64 }), 'base64'); // The server's count is authoritative: it is the only thing that knows what actually arrived. let sent = session.ReceivedBytes ?? 0; options.onProgress?.({ sentBytes: sent, totalBytes: pending.byteSize }); @@ -163,7 +158,7 @@ export const runUpload = async (pending: PendingUpload, options: UploadOptions = return { ok: false, code: 'cancelled', sentBytes: sent, uploadId: session.UploadId }; } const size = Math.min(chunkSize, pending.byteSize - sent); - const data = chunkOf(base64, sent, size); + const data = chunkOf(bytes, sent, size); const updated = (await uploadRecordChunk({ UploadId: session.UploadId, Offset: sent, Data: data }, options.signal))?.Data; if (!updated) { return { ok: false, code: 'chunk_failed', sentBytes: sent, uploadId: session.UploadId }; diff --git a/src/stores/app/__tests__/core-store.test.ts b/src/stores/app/__tests__/core-store.test.ts index 36bff71d..23ddeb39 100644 --- a/src/stores/app/__tests__/core-store.test.ts +++ b/src/stores/app/__tests__/core-store.test.ts @@ -478,6 +478,37 @@ describe('Core Store', () => { expect(useCoreStore.getState().isLoading).toBe(false); expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'Active unit missing from refreshed units, keeping the current unit' })); }); + + it('refuses a switch to a unit the refreshed list does not contain, keeping id, unit and status together', async () => { + const currentStatus = { UnitId: 'unit-1', State: 'Available' }; + useCoreStore.setState({ activeUnitStatus: currentStatus as any }); + (useUnitsStore.getState as jest.Mock).mockReturnValue({ fetchUnits: jest.fn(async () => undefined), units: [currentUnit], unitStatuses: [] }); + + await useCoreStore.getState().setActiveUnitWithFetch('unit-2'); + + const state = useCoreStore.getState(); + expect(state.activeUnitId).toBe('unit-1'); + expect(state.activeUnit).toBe(currentUnit); + expect(state.activeUnitStatus).toBe(currentStatus); + expect(state.isLoading).toBe(false); + expect(getUnitStatus).not.toHaveBeenCalledWith('unit-2'); + }); + + it('drops a refreshed status once another unit has been selected', async () => { + const otherUnit = { UnitId: 'unit-2', Name: 'Engine 7', Type: '3' } as any; + let releaseStatus!: (value: unknown) => void; + (useUnitsStore.getState as jest.Mock).mockReturnValue({ fetchUnits: jest.fn(async () => undefined), units: [currentUnit, otherUnit], unitStatuses: [] }); + (getUnitStatus as jest.Mock).mockImplementationOnce(() => new Promise((resolve) => (releaseStatus = resolve))); + + const refresh = useCoreStore.getState().setActiveUnitWithFetch('unit-1'); + await new Promise((resolve) => setImmediate(resolve)); + useCoreStore.setState({ activeUnitId: 'unit-2', activeUnit: otherUnit }); + releaseStatus({ Data: { UnitId: 'unit-1', State: 'Responding' } }); + await refresh; + + expect(useCoreStore.getState().activeUnit).toBe(otherUnit); + expect(useCoreStore.getState().activeUnitStatus).not.toEqual({ UnitId: 'unit-1', State: 'Responding' }); + }); }); describe('Store State', () => { diff --git a/src/stores/app/core-store.ts b/src/stores/app/core-store.ts index d824f273..54b0a316 100644 --- a/src/stores/app/core-store.ts +++ b/src/stores/app/core-store.ts @@ -255,6 +255,7 @@ export const useCoreStore = create()( } }, setActiveUnitWithFetch: async (unitId: string) => { + const previousUnitId = get().activeUnitId; set({ isLoading: true, error: null, activeUnitId: unitId }); try { await useUnitsStore.getState().fetchUnits(); @@ -262,8 +263,6 @@ export const useCoreStore = create()( const units = useUnitsStore.getState().units; const activeUnit = units.find((unit) => unit.UnitId === unitId); - const unitStatus = await getUnitStatus(unitId); - // fetchUnits() swallows its own failures, so the list can be empty or stale here. Writing // `undefined` over the unit we already hold would silently disable every status submit // (they all require an active unit) until the app restarts — keep the one we have. @@ -272,6 +271,20 @@ export const useCoreStore = create()( message: 'Active unit missing from refreshed units, keeping the current unit', context: { unitId, unitCount: units.length }, }); + // Keeping it is only right when it is the unit being refreshed. A switch to another unit + // is refused whole, so the id, the unit and its status never describe different units. + if (get().activeUnit?.UnitId !== unitId) { + set({ activeUnitId: previousUnitId, error: 'Failed to fetch and set active unit', isLoading: false }); + return; + } + } + + const unitStatus = await getUnitStatus(unitId); + + // Another unit was selected while this refresh was in flight (that switch owns the loading + // state now); this answer is not for the unit that is active. + if (get().activeUnitId !== unitId) { + return; } set({ diff --git a/src/stores/contacts/__tests__/preplan-store.test.ts b/src/stores/contacts/__tests__/preplan-store.test.ts new file mode 100644 index 00000000..29425403 --- /dev/null +++ b/src/stores/contacts/__tests__/preplan-store.test.ts @@ -0,0 +1,69 @@ +import { useContactPreplanStore } from '@/stores/contacts/preplan-store'; + +jest.mock('@/api/contacts/contactPreplans', () => ({ + getContactPreplan: jest.fn(), +})); + +jest.mock('@/api/contacts/contactFiles', () => ({ + getContactFiles: jest.fn(), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const preplansApi = jest.requireMock('@/api/contacts/contactPreplans'); +const filesApi = jest.requireMock('@/api/contacts/contactFiles'); + +const preplan = (contactId: string, occupancyNotes: string) => ({ ContactPreplanId: `p-${contactId}`, ContactId: contactId, IsProtected: true, OccupancyNotes: occupancyNotes }); + +describe('useContactPreplanStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + useContactPreplanStore.getState().reset(); + }); + + it('serves a fetched pre-plan from the cache until it is forced', async () => { + preplansApi.getContactPreplan.mockResolvedValue({ Data: preplan('c1', 'Knox box north') }); + + await useContactPreplanStore.getState().fetchPreplan('c1'); + await useContactPreplanStore.getState().fetchPreplan('c1'); + expect(preplansApi.getContactPreplan).toHaveBeenCalledTimes(1); + + await useContactPreplanStore.getState().fetchPreplan('c1', true); + expect(preplansApi.getContactPreplan).toHaveBeenCalledTimes(2); + expect(useContactPreplanStore.getState().preplans.c1?.OccupancyNotes).toBe('Knox box north'); + }); + + it('does not cache a pre-plan or files that answer after sign-out', async () => { + let releasePreplan!: (value: unknown) => void; + let releaseFiles!: (value: unknown) => void; + preplansApi.getContactPreplan.mockReturnValueOnce(new Promise((resolve) => (releasePreplan = resolve))); + filesApi.getContactFiles.mockReturnValueOnce(new Promise((resolve) => (releaseFiles = resolve))); + + const loadingPreplan = useContactPreplanStore.getState().fetchPreplan('c1'); + const loadingFiles = useContactPreplanStore.getState().fetchFiles('c1'); + useContactPreplanStore.getState().reset(); + releasePreplan({ Data: preplan('c1', 'Revealed under the previous grant') }); + releaseFiles({ Data: [{ Id: 'f1', FileName: 'site.pdf' }] }); + await Promise.all([loadingPreplan, loadingFiles]); + + const state = useContactPreplanStore.getState(); + expect(state.preplans).toEqual({}); + expect(state.files).toEqual({}); + expect(state.loadingPreplan).toEqual({}); + expect(state.loadingFiles).toEqual({}); + }); + + it('ignores a failure that lands after sign-out', async () => { + let rejectPreplan!: (reason: unknown) => void; + preplansApi.getContactPreplan.mockReturnValueOnce(new Promise((_resolve, reject) => (rejectPreplan = reject))); + + const loading = useContactPreplanStore.getState().fetchPreplan('c1'); + useContactPreplanStore.getState().reset(); + rejectPreplan(new Error('Network Error')); + await loading; + + expect(useContactPreplanStore.getState().error).toBeNull(); + }); +}); diff --git a/src/stores/contacts/preplan-store.ts b/src/stores/contacts/preplan-store.ts index a8cac0c8..d189b02a 100644 --- a/src/stores/contacts/preplan-store.ts +++ b/src/stores/contacts/preplan-store.ts @@ -20,6 +20,10 @@ interface ContactPreplanState { reset: () => void; } +// Bumped by reset(). An answer still in flight at sign-out must not land in the cache afterwards: the +// cache is served without a re-fetch, so the next sign-in would see what the previous grant revealed. +let sessionGeneration = 0; + /** * Pre-plan and site-file cache for the contact details sheet (Contacts plan Phase A). Cached per contact * for the life of the sheet; `force` re-fetches after a step-up so REDACTED values are replaced. @@ -35,15 +39,18 @@ export const useContactPreplanStore = create((set, get) => if (!contactId) return; if (!force && Object.prototype.hasOwnProperty.call(get().preplans, contactId)) return; + const started = sessionGeneration; set((state) => ({ loadingPreplan: { ...state.loadingPreplan, [contactId]: true }, error: null })); try { const result = await getContactPreplan(contactId); + if (started !== sessionGeneration) return; set((state) => ({ preplans: { ...state.preplans, [contactId]: result.Data ?? null }, loadingPreplan: { ...state.loadingPreplan, [contactId]: false }, })); } catch (error) { logger.error({ message: 'Failed to fetch contact pre-plan', context: { error, contactId } }); + if (started !== sessionGeneration) return; set((state) => ({ loadingPreplan: { ...state.loadingPreplan, [contactId]: false }, error: error instanceof Error ? error.message : 'Failed to fetch contact pre-plan', @@ -55,15 +62,18 @@ export const useContactPreplanStore = create((set, get) => if (!contactId) return; if (!force && Object.prototype.hasOwnProperty.call(get().files, contactId)) return; + const started = sessionGeneration; set((state) => ({ loadingFiles: { ...state.loadingFiles, [contactId]: true }, error: null })); try { const result = await getContactFiles(contactId, false); + if (started !== sessionGeneration) return; set((state) => ({ files: { ...state.files, [contactId]: result.Data ?? [] }, loadingFiles: { ...state.loadingFiles, [contactId]: false }, })); } catch (error) { logger.error({ message: 'Failed to fetch contact files', context: { error, contactId } }); + if (started !== sessionGeneration) return; set((state) => ({ loadingFiles: { ...state.loadingFiles, [contactId]: false }, error: error instanceof Error ? error.message : 'Failed to fetch contact files', @@ -80,5 +90,8 @@ export const useContactPreplanStore = create((set, get) => return { preplans, files }; }), - reset: () => set({ preplans: {}, files: {}, loadingPreplan: {}, loadingFiles: {}, error: null }), + reset: () => { + sessionGeneration += 1; + set({ preplans: {}, files: {}, loadingPreplan: {}, loadingFiles: {}, error: null }); + }, })); diff --git a/src/stores/operations/__tests__/store.test.ts b/src/stores/operations/__tests__/store.test.ts index 5c8a52ff..299b1a4d 100644 --- a/src/stores/operations/__tests__/store.test.ts +++ b/src/stores/operations/__tests__/store.test.ts @@ -129,6 +129,21 @@ it('opens the crew report for the scope on demand, refuses invalid entries local expect(useOperationsStore.getState().report?.Status).toBe(2); }); +it('drops an access answer that lands after the sign-in changed', async () => { + let releaseFirst!: (value: never) => void; + server.getDeploymentAccess.mockReturnValueOnce(new Promise((resolve) => (releaseFirst = resolve)) as never).mockResolvedValueOnce({ Enabled: false, CanManage: false, CanApproveTimeReports: false, ContractorBilling: false }); + const previous = useOperationsStore.getState().loadAccess(); + + useAuthStore.setState({ userId: 'someone-else' }); + await useOperationsStore.getState().loadAccess(); + releaseFirst({ Enabled: true, CanManage: true, CanApproveTimeReports: true, ContractorBilling: false } as never); + await previous; + + expect(useOperationsStore.getState().identity).toBe('someone-else:77'); + expect(useOperationsStore.getState().access?.CanManage).toBe(false); + useAuthStore.setState({ userId: 'me' }); +}); + it('drops a deployment that answers after the person left it or opened another', async () => { let releaseFirst!: (value: never) => void; server.getDeployment.mockReturnValueOnce(new Promise((resolve) => (releaseFirst = resolve)) as never); diff --git a/src/stores/operations/store.ts b/src/stores/operations/store.ts index 67548317..c5c4f9f3 100644 --- a/src/stores/operations/store.ts +++ b/src/stores/operations/store.ts @@ -180,6 +180,8 @@ export const useOperationsStore = create()((set, get) => { operationsCapabilities.recordUsage ? getFieldCostAccess().catch(() => null) : Promise.resolve(null), operationsCapabilities.draftF42 ? getCalOesMarsAccess().catch(() => null) : Promise.resolve(null), ]); + // The sign-in changed while this was in flight: the answer describes the previous identity. + if (currentIdentity() !== identity || get().identity !== identity) return; set({ access, costAccess, marsAccess }); }); }, diff --git a/src/stores/records/__tests__/deployments-store.test.ts b/src/stores/records/__tests__/deployments-store.test.ts index eb13359e..28f17eea 100644 --- a/src/stores/records/__tests__/deployments-store.test.ts +++ b/src/stores/records/__tests__/deployments-store.test.ts @@ -229,6 +229,19 @@ describe('Deployments store conformance', () => { expect(useDeploymentsStore.getState().connectorsError).toBe('forbidden'); }); + it('does not write back a deployments answer that lands after sign-out', async () => { + let release!: (value: unknown) => void; + api.getRecordDeployments.mockReturnValueOnce(new Promise((resolve) => (release = resolve))); + + const loading = useDeploymentsStore.getState().fetchDeployments(); + useDeploymentsStore.getState().reset(); + release({ Data: [deployment('o1', '2026-09-01T00:00:00Z')] }); + await loading; + + expect(useDeploymentsStore.getState().deployments).toEqual([]); + expect(useDeploymentsStore.getState().lastFetchedOn).toBeNull(); + }); + it('reset drops everything, including connector rows that must not outlive the session', () => { useDeploymentsStore.setState({ deployments: [deployment('o1', '2026-09-01T00:00:00Z')], connectors: [connector('c1', 'Alpha')], runs: { c1: [run('r1')] }, reconciliation: [], error: 'x', connectorsError: 'y' }); diff --git a/src/stores/records/__tests__/store.test.ts b/src/stores/records/__tests__/store.test.ts index 3236a8a2..f45a766f 100644 --- a/src/stores/records/__tests__/store.test.ts +++ b/src/stores/records/__tests__/store.test.ts @@ -62,6 +62,17 @@ const resetStore = () => { }); }; +const catalogOf = (definitionKey = 'shift-log', overrides: Parameters[0] = {}) => ({ + ContractVersion: 'field-catalog.v1', + OriginClient: 'Unit', + Ok: true, + Reasons: [], + ContextVerified: true, + Definitions: [goldenCatalogEntry({ DefinitionKey: definitionKey, ...overrides })], + Exclusions: [], + ServerTimestampMs: 0, +}); + const summary = (id: string, state: number, modifiedOn: string) => ({ RecordId: id, DefinitionVersion: 3, @@ -303,4 +314,86 @@ describe('Field Records store conformance', () => { expect(useRecordsStore.getState().catalog).toBeNull(); expect(useRecordsStore.getState().context).toEqual({ CallId: 501 }); }); + + it('treats a different contact as a different context', () => { + useRecordsStore.getState().setContext({ CallId: 501, ContactId: 7 }); + useRecordsStore.setState({ catalog: catalogOf() }); + + useRecordsStore.getState().setContext({ CallId: 501, ContactId: 8 }); + + expect(useRecordsStore.getState().context).toEqual({ CallId: 501, ContactId: 8 }); + expect(useRecordsStore.getState().catalog).toBeNull(); + }); + + it('never lets a catalog asked for an earlier context replace the current one', async () => { + let releaseFirst!: (value: unknown) => void; + fieldApi.getFieldRecordsCatalog.mockReturnValueOnce(new Promise((resolve) => (releaseFirst = resolve))).mockResolvedValueOnce({ Data: catalogOf('call-502-form') }); + + useRecordsStore.getState().setContext({ CallId: 501 }); + const earlier = useRecordsStore.getState().fetchCatalog(); + useRecordsStore.getState().setContext({ CallId: 502 }); + await useRecordsStore.getState().fetchCatalog(); + releaseFirst({ Data: catalogOf('call-501-form') }); + + expect(await earlier).toBeNull(); + expect(useRecordsStore.getState().catalog?.Definitions.map((entry) => entry.DefinitionKey)).toEqual(['call-502-form']); + }); + + it('drops a sync bundle that answers after the context changed', async () => { + let release!: (value: unknown) => void; + fieldApi.syncFieldRecords.mockReturnValueOnce(new Promise((resolve) => (release = resolve))); + + useRecordsStore.getState().setContext({ CallId: 501 }); + const syncing = useRecordsStore.getState().sync(); + useRecordsStore.getState().setContext({ CallId: 502 }); + release({ Data: { Ok: true, ResetRequired: false, ScopeStamp: 'scope-501', ServerTimestampMs: 700, Catalog: catalogOf('call-501-form'), Records: [summary('r1', RmsRecordState.Finalized, '2026-09-01T00:00:00Z')], Tombstones: [], Drafts: [], Assignments: [] } }); + await syncing; + + const state = useRecordsStore.getState(); + expect(state.catalog).toBeNull(); + expect(state.recent).toEqual([]); + expect(state.lastSyncTimestampMs).toBe(0); + expect(state.isSyncing).toBe(false); + }); + + it('removes a protected draft staged before the catalog loaded once its send fails', async () => { + const draft = { clientRecordId: 'draft-early', recordId: null, definitionKey: 'shift-log', definitionVersion: 3, name: 'Shift log', values: [], updatedOn: '2026-09-06T00:00:00Z' }; + useRecordsStore.getState().stageDraft(draft); + expect(useRecordsStore.getState().pendingDrafts['draft-early']).toBeDefined(); + + useRecordsStore.setState({ catalog: catalogOf('shift-log', { RequiresProtectedGrant: true }) }); + recordsApi.createRecordDraft.mockRejectedValueOnce({ response: { status: 403, data: { type: 'protected_data_required', title: 'Grant required' } } }); + + const result = await useRecordsStore.getState().pushDraft('draft-early'); + + expect(result.ok).toBe(false); + expect(useRecordsStore.getState().pendingDrafts).toEqual({}); + }); + + it('does not restore a draft whose send fails after sign-out', async () => { + let reject!: (reason: unknown) => void; + recordsApi.createRecordDraft.mockReturnValueOnce(new Promise((_resolve, rejectWith) => (reject = rejectWith))); + useRecordsStore.getState().stageDraft({ clientRecordId: 'draft-old', recordId: null, definitionKey: 'shift-log', definitionVersion: 3, name: 'Shift log', values: [], updatedOn: '2026-09-06T00:00:00Z' }); + + const pushing = useRecordsStore.getState().pushDraft('draft-old'); + useRecordsStore.getState().reset(); + reject({ response: { status: 500, data: { title: 'Unavailable' } } }); + await pushing; + + expect(useRecordsStore.getState().pendingDrafts).toEqual({}); + }); + + it('stops an upload batch at sign-out and writes nothing back', async () => { + const upload = (localId: string) => ({ localId, recordId: 'r1', uploadId: null, fileUri: `file:///${localId}.jpg`, fileName: `${localId}.jpg`, contentType: 'image/jpeg', byteSize: 9, sha256: 'abc', sentBytes: 0, classification: 1, createdOn: '2026-09-06T00:00:00Z' }); + useRecordsStore.setState({ pendingUploads: { first: upload('first'), second: upload('second') } }); + uploads.runUpload.mockImplementationOnce(async () => { + useRecordsStore.getState().reset(); + return { ok: false, code: 'failed', sentBytes: 3, uploadId: 'session-1' }; + }); + + await useRecordsStore.getState().runUploads(); + + expect(uploads.runUpload).toHaveBeenCalledTimes(1); + expect(useRecordsStore.getState().pendingUploads).toEqual({}); + }); }); diff --git a/src/stores/records/deployments-store.ts b/src/stores/records/deployments-store.ts index 9e2e7bd1..c2b3a652 100644 --- a/src/stores/records/deployments-store.ts +++ b/src/stores/records/deployments-store.ts @@ -62,6 +62,20 @@ const messageOf = (error: unknown, fallback: string): string => { return error instanceof Error && error.message ? error.message : fallback; }; +// Bumped by reset(). An answer still in flight at sign-out must not write the previous session's +// deployments back into the store, and through persistence back onto the device. +let sessionGeneration = 0; + +/** A setter bound to the session a request started in; it does nothing once reset() has run. */ +const sessionSet = (set: (partial: Partial) => void) => { + const started = sessionGeneration; + return (partial: Partial) => { + if (started === sessionGeneration) { + set(partial); + } + }; +}; + export const useDeploymentsStore = create()( persist( (set, get) => ({ @@ -77,98 +91,104 @@ export const useDeploymentsStore = create()( lastFetchedOn: null, fetchDeployments: async (options) => { + const commit = sessionSet(set); const includeClosed = options?.includeClosed ?? get().includeClosed; set({ isLoading: true, error: null, includeClosed }); try { const response = await getRecordDeployments(includeClosed); const deployments = sortDeployments(response?.Data ?? []); - set({ deployments, isLoading: false, lastFetchedOn: new Date().toISOString() }); + commit({ deployments, isLoading: false, lastFetchedOn: new Date().toISOString() }); return deployments; } catch (error) { logger.error({ message: 'Deployments fetch failed', context: { error } }); // Offline keeps what was last read; a refusal drops it rather than showing it past revoked access. - set({ isLoading: false, error: messageOf(error, 'load_failed'), ...(isAccessAnswer(error) ? { deployments: [] } : {}) }); + commit({ isLoading: false, error: messageOf(error, 'load_failed'), ...(isAccessAnswer(error) ? { deployments: [] } : {}) }); return get().deployments; } }, fetchDeployment: async (orderId) => { + const commit = sessionSet(set); set({ isLoading: true, error: null }); try { const response = await getRecordDeployment(orderId); const deployment = response?.Data ?? null; if (deployment) { - set({ deployments: upsertDeployment(get().deployments, deployment), isLoading: false }); + commit({ deployments: upsertDeployment(get().deployments, deployment), isLoading: false }); } else { // Gone or no longer visible: it is dropped rather than shown from the last fetch. - set({ deployments: get().deployments.filter((existing) => existing.OrderId !== orderId), isLoading: false, error: 'not_found' }); + commit({ deployments: get().deployments.filter((existing) => existing.OrderId !== orderId), isLoading: false, error: 'not_found' }); } return deployment; } catch (error) { logger.error({ message: 'Deployment fetch failed', context: { error, orderId } }); if (isAccessAnswer(error)) { // Refused or gone: dropped the same way as an empty answer, never shown from the last fetch. - set({ deployments: get().deployments.filter((existing) => existing.OrderId !== orderId), isLoading: false, error: messageOf(error, 'load_failed') }); + commit({ deployments: get().deployments.filter((existing) => existing.OrderId !== orderId), isLoading: false, error: messageOf(error, 'load_failed') }); return null; } - set({ isLoading: false, error: messageOf(error, 'load_failed') }); + commit({ isLoading: false, error: messageOf(error, 'load_failed') }); return get().deployments.find((existing) => existing.OrderId === orderId) ?? null; } }, fetchReconciliation: async (connectorId) => { + const commit = sessionSet(set); try { const response = await getRecordDeploymentReconciliation(connectorId); const items = response?.Data ?? []; // One connector's items replace only that connector's slice; the rest stays as last seen. const kept = connectorId ? get().reconciliation.filter((item) => item.ConnectorId !== connectorId) : []; - set({ reconciliation: [...kept, ...items], connectorsError: null }); + commit({ reconciliation: [...kept, ...items], connectorsError: null }); return items; } catch (error) { logger.error({ message: 'Reconciliation fetch failed', context: { error, connectorId } }); - set({ connectorsError: messageOf(error, 'load_failed') }); + commit({ connectorsError: messageOf(error, 'load_failed') }); return []; } }, fetchConnectors: async () => { + const commit = sessionSet(set); set({ connectorsError: null }); try { const response = await getRecordDeploymentConnectors(); const connectors = (response?.Data ?? []).slice().sort((a, b) => a.Name.localeCompare(b.Name)); - set({ connectors }); + commit({ connectors }); return connectors; } catch (error) { logger.error({ message: 'Connectors fetch failed', context: { error } }); - set({ connectorsError: messageOf(error, 'load_failed') }); + commit({ connectorsError: messageOf(error, 'load_failed') }); return []; } }, fetchConnector: async (connectorId) => { + const commit = sessionSet(set); try { const response = await getRecordDeploymentConnector(connectorId); const connector = response?.Data ?? null; if (connector) { - set({ connectors: [connector, ...get().connectors.filter((existing) => existing.Id !== connectorId)].sort((a, b) => a.Name.localeCompare(b.Name)), connectorsError: null }); + commit({ connectors: [connector, ...get().connectors.filter((existing) => existing.Id !== connectorId)].sort((a, b) => a.Name.localeCompare(b.Name)), connectorsError: null }); } return connector; } catch (error) { logger.error({ message: 'Connector fetch failed', context: { error, connectorId } }); - set({ connectorsError: messageOf(error, 'load_failed') }); + commit({ connectorsError: messageOf(error, 'load_failed') }); return get().connectors.find((existing) => existing.Id === connectorId) ?? null; } }, fetchRuns: async (connectorId) => { + const commit = sessionSet(set); try { const response = await getRecordDeploymentConnectorRuns(connectorId); const runs = response?.Data ?? []; - set({ runs: { ...get().runs, [connectorId]: runs } }); + commit({ runs: { ...get().runs, [connectorId]: runs } }); return runs; } catch (error) { logger.error({ message: 'Connector runs fetch failed', context: { error, connectorId } }); - set({ connectorsError: messageOf(error, 'load_failed') }); + commit({ connectorsError: messageOf(error, 'load_failed') }); return get().runs[connectorId] ?? []; } }, @@ -177,28 +197,30 @@ export const useDeploymentsStore = create()( if (get().runningConnectorId) { return { ok: false, error: 'busy' }; } + const commit = sessionSet(set); set({ runningConnectorId: connectorId, connectorsError: null }); try { const response = await runRecordDeploymentConnector(connectorId); const run = response?.Data; if (!run) { - set({ runningConnectorId: null, connectorsError: 'load_failed' }); + commit({ runningConnectorId: null, connectorsError: 'load_failed' }); return { ok: false, error: 'load_failed' }; } // The run row is the newest entry in that connector's log; then everything it may have // changed is re-read from the server rather than guessed at from the counts. - set({ runs: { ...get().runs, [connectorId]: [run, ...(get().runs[connectorId] ?? []).filter((existing) => existing.Id !== run.Id)] }, runningConnectorId: null }); + commit({ runs: { ...get().runs, [connectorId]: [run, ...(get().runs[connectorId] ?? []).filter((existing) => existing.Id !== run.Id)] }, runningConnectorId: null }); await Promise.all([get().fetchConnector(connectorId), get().fetchReconciliation(connectorId), get().fetchDeployments()]); return { ok: run.Outcome === 'ok', run, error: run.Outcome === 'ok' ? undefined : (run.Error ?? run.Outcome) }; } catch (error) { logger.error({ message: 'Connector run failed', context: { error, connectorId } }); const message = messageOf(error, 'run_failed'); - set({ runningConnectorId: null, connectorsError: message }); + commit({ runningConnectorId: null, connectorsError: message }); return { ok: false, error: message }; } }, - reset: () => + reset: () => { + sessionGeneration += 1; set({ deployments: [], includeClosed: false, @@ -210,7 +232,8 @@ export const useDeploymentsStore = create()( error: null, connectorsError: null, lastFetchedOn: null, - }), + }); + }, }), { name: 'records-deployments-storage', diff --git a/src/stores/records/store.ts b/src/stores/records/store.ts index 96b50e30..d7d7c9c0 100644 --- a/src/stores/records/store.ts +++ b/src/stores/records/store.ts @@ -59,6 +59,10 @@ export interface FieldRecordTelemetryEvent { // make every consumer of the store re-render each time an event is added. let telemetryQueue: FieldRecordTelemetryEvent[] = []; +// Bumped by reset(). A send still in flight at sign-out must not write the previous session's draft or +// upload back into the persisted slice, where the next sign-in would pick it up and send it as theirs. +let sessionGeneration = 0; + // The upload driver reaches for the camera roll and the crypto module, so it is loaded only when an // upload actually runs. Importing it here would drag those native modules into every screen that // touches this store, which is most of them. @@ -198,7 +202,7 @@ export const useRecordsStore = create()( setContext: (context) => { const current = get().context; - if (current.CallId === context.CallId && current.UnitId === context.UnitId && current.GroupId === context.GroupId && current.CommandRole === context.CommandRole) { + if (current.CallId === context.CallId && current.UnitId === context.UnitId && current.GroupId === context.GroupId && current.CommandRole === context.CommandRole && current.ContactId === context.ContactId) { return; } // The catalog is context-specific, so it is dropped rather than shown against a new context. @@ -219,15 +223,23 @@ export const useRecordsStore = create()( }, fetchCatalog: async () => { + // setContext() only replaces the context when it changes (and reset() always does), so an + // answer is committed only while the context it was asked for is still the current one. + const context = get().context; set({ isLoading: true }); try { - const response = await getFieldRecordsCatalog(catalogInput(get().context)); + const response = await getFieldRecordsCatalog(catalogInput(context)); + if (get().context !== context) { + return null; + } const catalog = response?.Data ?? null; set({ catalog, scopeStamp: catalog?.ScopeStamp ?? get().scopeStamp, error: null }); return catalog; } catch (error) { logger.error({ message: 'Field Records catalog failed', context: { error } }); - set({ error: messageFrom(error) }); + if (get().context === context) { + set({ error: messageFrom(error) }); + } return null; } finally { set({ isLoading: false }); @@ -243,16 +255,19 @@ export const useRecordsStore = create()( // A reset answers with no page, so the full re-pull is run after this one releases the // in-flight guard; recursing inside it would be swallowed by that same guard. let resetAndRetry = false; + const context = get().context; try { const response = await syncFieldRecords({ - ...catalogInput(get().context), + ...catalogInput(context), Since: full ? 0 : get().lastSyncTimestampMs, ScopeStamp: full ? null : get().scopeStamp, Take: SYNC_TAKE, IncludeCatalog: true, }); const bundle = response?.Data; - if (!bundle) { + // A bundle for a context (or a session) that is no longer current is dropped whole: its + // records, catalog and cursor all belong to what was being viewed when it was asked for. + if (!bundle || get().context !== context) { return; } @@ -298,7 +313,9 @@ export const useRecordsStore = create()( } catch (error) { logger.error({ message: 'Field Records sync failed', context: { error } }); get().report({ EventType: 'sync', Outcome: 'failed' }); - set({ error: messageFrom(error) }); + if (get().context === context) { + set({ error: messageFrom(error) }); + } } finally { set({ isSyncing: false }); } @@ -354,6 +371,7 @@ export const useRecordsStore = create()( }, pushDraft: async (clientRecordId, supplied) => { + const generation = sessionGeneration; const draft = supplied ?? get().pendingDrafts[clientRecordId]; if (!draft) { return { ok: false, error: 'not_found' }; @@ -382,22 +400,32 @@ export const useRecordsStore = create()( throw new Error('The server did not return a record.'); } get().discardDraft(clientRecordId); - get().report({ EventType: 'draft_saved', Outcome: 'ok', RecordId: recordId, DefinitionKey: draft.definitionKey, DefinitionVersion: draft.definitionVersion }); - void get().sync(); + // Telemetry and the follow-up sync belong to the session that sent it, not to a later sign-in. + if (generation === sessionGeneration) { + get().report({ EventType: 'draft_saved', Outcome: 'ok', RecordId: recordId, DefinitionKey: draft.definitionKey, DefinitionVersion: draft.definitionVersion }); + void get().sync(); + } return { ok: true, recordId }; } catch (error) { const conflict = conflictFrom(error); - // Never replayed silently: the draft is kept and flagged so a person decides what happens — - // unless its definition seals values, which are never left on the device. + logger.error({ message: 'Record draft push failed', context: { error, clientRecordId, conflict } }); + if (generation !== sessionGeneration) { + // Signed out while this was in flight: the draft belonged to that session and is not restored. + return { ok: false, conflict, error: messageFrom(error) }; + } if (mayKeepOnDevice(get().entryFor(draft.definitionKey, draft.definitionVersion))) { + // Never replayed silently: the draft is kept and flagged so a person decides what happens. set({ pendingDrafts: { ...get().pendingDrafts, [clientRecordId]: { ...draft, lastError: messageFrom(error), conflict: conflict ?? null }, }, }); + } else { + // Its definition seals values, which are never left on the device. A copy staged before the + // catalog said so (or before it had loaded) is removed rather than kept after the failed send. + get().discardDraft(clientRecordId); } - logger.error({ message: 'Record draft push failed', context: { error, clientRecordId, conflict } }); get().report({ EventType: 'draft_saved', Outcome: conflict ?? 'failed', DefinitionKey: draft.definitionKey, DefinitionVersion: draft.definitionVersion }); if (conflict) { get().report({ EventType: 'conflict', Outcome: conflict, DefinitionKey: draft.definitionKey }); @@ -474,16 +502,29 @@ export const useRecordsStore = create()( return { uploaded, failed }; } const { runUpload } = loadUploader(); + const generation = sessionGeneration; for (const upload of uploads) { + // Signed out mid-batch: the rest belong to that session and are neither sent nor written back. + if (generation !== sessionGeneration) { + break; + } // A file that is gone cannot be uploaded by trying again; it waits for the person instead. if (upload.isUnrecoverable) { continue; } const startedAt = Date.now(); const outcome = await runUpload(upload, { - onProgress: ({ sentBytes }) => set({ uploadProgress: { ...get().uploadProgress, [upload.localId]: sentBytes } }), + onProgress: ({ sentBytes }) => { + if (generation === sessionGeneration) { + set({ uploadProgress: { ...get().uploadProgress, [upload.localId]: sentBytes } }); + } + }, + shouldCancel: () => generation !== sessionGeneration, }); + if (generation !== sessionGeneration) { + break; + } if (outcome.ok) { uploaded += 1; @@ -573,6 +614,7 @@ export const useRecordsStore = create()( // Queued telemetry belongs to the identity that produced it; a reset drops it rather than // letting the next session flush another member's counts under their own name. telemetryQueue = []; + sessionGeneration += 1; set({ preflight: null, catalog: null, From b6a66adf8d50bd44250075ad2dced6ed9418625a Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 25 Sep 2026 23:29:28 -0700 Subject: [PATCH 6/8] RU-T53 PR #287 fixes --- src/app/records/[id].tsx | 108 ++++++++---- src/app/records/__tests__/[id].test.tsx | 197 ++++++++++++++++++++++ src/lib/records/__tests__/uploads.test.ts | 19 +++ src/lib/records/uploads.ts | 10 +- 4 files changed, 295 insertions(+), 39 deletions(-) create mode 100644 src/app/records/__tests__/[id].test.tsx diff --git a/src/app/records/[id].tsx b/src/app/records/[id].tsx index beddcaa2..7b0df901 100644 --- a/src/app/records/[id].tsx +++ b/src/app/records/[id].tsx @@ -42,27 +42,39 @@ export default function RecordScreen() { const [attested, setAttested] = useState(false); const [isBusy, setIsBusy] = useState(false); const [message, setMessage] = useState(null); + // Edits made since the record was last loaded; they exist only here until they are saved. + const [dirty, setDirty] = useState(false); - const load = useCallback(async () => { + /** Loads the record as the server holds it now, replacing any local edits. */ + const fetchRecord = useCallback(async (): Promise => { if (!id) { - return; + return null; } - setIsBusy(true); try { const response = await getRecord(id); const loaded = response?.Data ?? null; setRecord(loaded); setValues(toValueMap(loaded?.Values?.Cells)); + setDirty(false); if (loaded?.DefinitionKey) { setSchema(await fetchSchema(loaded.DefinitionKey, loaded.DefinitionVersion)); } + return loaded; } catch (error) { logger.error({ message: 'Record load failed', context: { error, id } }); setMessage(t('records.load_failed')); + return null; + } + }, [id, fetchSchema, t]); + + const load = useCallback(async () => { + setIsBusy(true); + try { + await fetchRecord(); } finally { setIsBusy(false); } - }, [id, fetchSchema, t]); + }, [fetchRecord]); useEffect(() => { void load(); @@ -72,6 +84,39 @@ export default function RecordScreen() { const editable = record ? record.State === RmsRecordState.Draft || record.State === RmsRecordState.Returned : false; const entry = record?.DefinitionKey ? entryFor(record.DefinitionKey, record.DefinitionVersion) : null; + const handleChange = useCallback((next: ValueMap) => { + setValues(next); + setDirty(true); + }, []); + + /** Sends the edited values and returns the record as the server now holds it, or null when it was not saved. */ + const persist = useCallback( + async (current: RecordData): Promise => { + const clientRecordId = `edit-${current.RecordId}-${current.RowVersion}`; + const draft = { + clientRecordId, + recordId: current.RecordId, + definitionKey: current.DefinitionKey ?? '', + definitionVersion: current.DefinitionVersion, + name: current.RecordNumber ?? current.DraftReference ?? entry?.Name ?? t('records.untitled'), + values: toValueList(values), + rowVersion: current.RowVersion, + updatedOn: new Date().toISOString(), + }; + stageDraft(draft); + // Passed directly as well: a definition that seals values is never staged on the device. + const result = await pushDraft(clientRecordId, draft); + if (!result.ok) { + // A conflict is shown, never resolved by overwriting: the person reloads and decides. + setMessage(result.conflict ? t(`records.conflict_${result.conflict.replace('-', '_')}`) : (result.error ?? t('records.save_failed'))); + return null; + } + discardDraft(clientRecordId); + return fetchRecord(); + }, + [values, entry, stageDraft, pushDraft, discardDraft, fetchRecord, t] + ); + const save = useCallback(async () => { if (!record || !schema) { return; @@ -84,48 +129,42 @@ export default function RecordScreen() { } setIsBusy(true); try { - const clientRecordId = `edit-${record.RecordId}-${record.RowVersion}`; - const draft = { - clientRecordId, - recordId: record.RecordId, - definitionKey: record.DefinitionKey ?? '', - definitionVersion: record.DefinitionVersion, - name: record.RecordNumber ?? record.DraftReference ?? entry?.Name ?? t('records.untitled'), - values: toValueList(values), - rowVersion: record.RowVersion, - updatedOn: new Date().toISOString(), - }; - stageDraft(draft); - // Passed directly as well: a definition that seals values is never staged on the device. - const result = await pushDraft(clientRecordId, draft); - if (result.ok) { - discardDraft(clientRecordId); + if (await persist(record)) { setMessage(t('records.saved')); - await load(); - return; } - // A conflict is shown, never resolved by overwriting: the person reloads and decides. - setMessage(result.conflict ? t(`records.conflict_${result.conflict.replace('-', '_')}`) : (result.error ?? t('records.save_failed'))); } finally { setIsBusy(false); } - }, [record, schema, values, entry, stageDraft, pushDraft, discardDraft, load, t]); + }, [record, schema, values, persist, t]); const submit = useCallback(async () => { if (!record) { return; } + if (dirty && schema) { + const found = validate(schema, values, false); + setIssues(found); + if (found.length > 0) { + setMessage(t('records.fix_fields')); + return; + } + } setIsBusy(true); try { - const result = await submitForReview(record.RecordId, record.RowVersion); + // Unsaved edits go first; otherwise the server's older copy is what would be put up for review. + const current = dirty ? await persist(record) : record; + if (!current) { + return; + } + const result = await submitForReview(current.RecordId, current.RowVersion); setMessage(result.ok ? t('records.submitted') : (result.error ?? t('records.save_failed'))); if (result.ok) { - await load(); + await fetchRecord(); } } finally { setIsBusy(false); } - }, [record, submitForReview, load, t]); + }, [record, schema, values, dirty, persist, submitForReview, fetchRecord, t]); const complete = useCallback(async () => { if (!record || !schema) { @@ -139,15 +178,20 @@ export default function RecordScreen() { } setIsBusy(true); try { - const result = await finalize(record.RecordId, record.RowVersion, attested); + // The values just validated are the ones finalized, so unsaved edits are sent first. + const current = dirty ? await persist(record) : record; + if (!current) { + return; + } + const result = await finalize(current.RecordId, current.RowVersion, attested); setMessage(result.ok ? t('records.finalized') : (result.error ?? t('records.save_failed'))); if (result.ok) { - await load(); + await fetchRecord(); } } finally { setIsBusy(false); } - }, [record, schema, values, attested, finalize, load, t]); + }, [record, schema, values, dirty, attested, persist, finalize, fetchRecord, t]); if (!record) { return ( @@ -178,7 +222,7 @@ export default function RecordScreen() { {message ? {message} : null} - {schema ? 0} forFinalize={false} onChange={setValues} /> : } + {schema ? 0} forFinalize={false} onChange={handleChange} /> : } {/* Attachments upload against a server-owned session, so an interruption resumes. */} diff --git a/src/app/records/__tests__/[id].test.tsx b/src/app/records/__tests__/[id].test.tsx new file mode 100644 index 00000000..9eabe138 --- /dev/null +++ b/src/app/records/__tests__/[id].test.tsx @@ -0,0 +1,197 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { getRecord } from '@/api/records/records'; +import { RmsRecordState } from '@/models/v4/records'; +import { useRecordsStore } from '@/stores/records/store'; + +import RecordScreen from '../[id]'; + +interface MockChildrenProps { + children?: React.ReactNode; + testID?: string; +} + +interface MockButtonProps extends MockChildrenProps { + onPress?: () => void; + isDisabled?: boolean; +} + +interface MockCheckboxProps extends MockChildrenProps { + onChange?: (checked: boolean) => void; +} + +interface MockRecordFormProps { + onChange: (values: Record) => void; +} + +jest.mock('expo-router', () => ({ + Stack: { Screen: () => null }, + useLocalSearchParams: () => ({ id: 'r1' }), + useRouter: () => ({ back: jest.fn() }), +})); + +jest.mock('react-i18next', () => { + // Stable like the real one: the screen's loaders depend on it. + const t = (key: string) => key; + return { useTranslation: () => ({ t }) }; +}); + +jest.mock('@/api/records/records', () => ({ + getRecord: jest.fn(), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +jest.mock('@/lib/records/schema', () => ({ + toValueList: jest.fn((values: Record) => Object.values(values)), + toValueMap: jest.fn(() => ({})), + unsupportedFieldKeys: jest.fn(() => []), + validate: jest.fn(() => []), +})); + +jest.mock('@/stores/records/store', () => ({ + useRecordsStore: jest.fn(), +})); + +jest.mock('@/components/records/record-attachments', () => ({ + RecordAttachments: () => null, +})); + +jest.mock('@/components/records/record-form', () => { + const { TouchableOpacity } = require('react-native'); + return { + RecordForm: ({ onChange }: MockRecordFormProps) => ( + onChange({ 'main:notes': { SectionKey: 'main', FieldKey: 'notes', Value: 'Edited' } })} /> + ), + }; +}); + +jest.mock('@/components/ui/button', () => { + const { Text, TouchableOpacity } = require('react-native'); + return { + Button: ({ children, onPress, isDisabled, testID }: MockButtonProps) => ( + + {children} + + ), + ButtonText: ({ children }: MockChildrenProps) => {children}, + }; +}); + +jest.mock('@/components/ui/checkbox', () => { + const { TouchableOpacity, View } = require('react-native'); + return { + Checkbox: ({ children, onChange, testID }: MockCheckboxProps) => ( + onChange?.(true)} testID={testID}> + {children} + + ), + CheckboxIcon: () => null, + CheckboxIndicator: ({ children }: MockChildrenProps) => {children}, + CheckboxLabel: () => null, + }; +}); + +jest.mock('@/components/ui/badge', () => { + const { Text, View } = require('react-native'); + return { + Badge: ({ children }: MockChildrenProps) => {children}, + BadgeText: ({ children }: MockChildrenProps) => {children}, + }; +}); + +jest.mock('@/components/ui/box', () => { + const { View } = require('react-native'); + return { Box: ({ children }: MockChildrenProps) => {children} }; +}); + +jest.mock('@/components/ui/vstack', () => { + const { View } = require('react-native'); + return { VStack: ({ children }: MockChildrenProps) => {children} }; +}); + +jest.mock('@/components/ui/heading', () => { + const { Text } = require('react-native'); + return { Heading: ({ children }: MockChildrenProps) => {children} }; +}); + +jest.mock('@/components/ui/spinner', () => { + const { View } = require('react-native'); + return { Spinner: () => }; +}); + +jest.mock('@/components/ui/text', () => { + const { Text } = require('react-native'); + return { Text }; +}); + +const mockGetRecord = getRecord as jest.MockedFunction; +const mockUseRecordsStore = useRecordsStore as unknown as jest.Mock; + +const record = (rowVersion: number) => ({ + RecordId: 'r1', + DefinitionKey: 'shift-log', + DefinitionVersion: 3, + RecordNumber: 'SL-1', + State: RmsRecordState.Draft, + RowVersion: rowVersion, + Values: { Cells: [] }, +}); + +const store = { + fetchSchema: jest.fn(), + stageDraft: jest.fn(), + pushDraft: jest.fn(), + discardDraft: jest.fn(), + submitForReview: jest.fn(), + finalize: jest.fn(), + entryFor: jest.fn(() => null), +}; + +describe('RecordScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRecordsStore.mockImplementation((selector: (state: typeof store) => unknown) => selector(store)); + store.fetchSchema.mockResolvedValue({ Sections: [] }); + store.pushDraft.mockResolvedValue({ ok: true, recordId: 'r1' }); + store.submitForReview.mockResolvedValue({ ok: true }); + store.finalize.mockResolvedValue({ ok: true }); + mockGetRecord.mockResolvedValueOnce({ Data: record(4) } as never).mockResolvedValue({ Data: record(5) } as never); + }); + + it('submits the stored record as it is when nothing was edited', async () => { + const { unmount } = render(); + fireEvent.press(await screen.findByTestId('record-submit')); + + await waitFor(() => expect(store.submitForReview).toHaveBeenCalledWith('r1', 4)); + expect(store.pushDraft).not.toHaveBeenCalled(); + unmount(); + }); + + it('saves unsaved edits before submitting, and submits the version that save produced', async () => { + const { unmount } = render(); + fireEvent.press(await screen.findByTestId('record-form-edit')); + fireEvent.press(screen.getByTestId('record-submit')); + + await waitFor(() => expect(store.submitForReview).toHaveBeenCalledWith('r1', 5)); + expect(store.pushDraft).toHaveBeenCalledWith('edit-r1-4', expect.objectContaining({ recordId: 'r1', rowVersion: 4, values: [expect.objectContaining({ Value: 'Edited' })] })); + expect(store.pushDraft.mock.invocationCallOrder[0]).toBeLessThan(store.submitForReview.mock.invocationCallOrder[0]); + unmount(); + }); + + it('does not finalize when the unsaved edits could not be saved', async () => { + store.pushDraft.mockResolvedValue({ ok: false, conflict: 'etag' }); + + const { unmount } = render(); + fireEvent.press(await screen.findByTestId('record-form-edit')); + fireEvent.press(screen.getByTestId('record-attest')); + fireEvent.press(screen.getByTestId('record-finalize')); + + expect(await screen.findByText('records.conflict_etag')).toBeTruthy(); + expect(store.finalize).not.toHaveBeenCalled(); + unmount(); + }); +}); diff --git a/src/lib/records/__tests__/uploads.test.ts b/src/lib/records/__tests__/uploads.test.ts index d7aec28a..c0482287 100644 --- a/src/lib/records/__tests__/uploads.test.ts +++ b/src/lib/records/__tests__/uploads.test.ts @@ -132,6 +132,25 @@ describe('Record attachment uploads', () => { ]); }); + it('cuts chunks at the server chunk size even when it is not a multiple of 3', async () => { + // The server declares 512 KiB, which is not a multiple of 3; it refuses any chunk that is not exactly + // that size except the last, so the chunks are cut from the bytes, not from the base64 text. + const bytes = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + fs.getInfoAsync.mockResolvedValue({ exists: true, size: 10 }); + fs.readAsStringAsync.mockResolvedValue(bytes.toString('base64')); + api.beginRecordUpload.mockResolvedValue(session(0, 4)); + api.uploadRecordChunk.mockResolvedValueOnce(session(4, 4)).mockResolvedValueOnce(session(8, 4)).mockResolvedValueOnce(session(10, 4)); + api.completeRecordUpload.mockResolvedValue({ Data: { AttachmentId: 'a5' } }); + + const outcome = await runUpload(pending({ byteSize: 10 }) as never); + + expect(outcome.ok).toBe(true); + const chunks: { Offset: number; Data: string }[] = api.uploadRecordChunk.mock.calls.map((call: unknown[]) => call[0] as { Offset: number; Data: string }); + expect(chunks.map((chunk) => chunk.Offset)).toEqual([0, 4, 8]); + expect(chunks.map((chunk) => Buffer.from(chunk.Data, 'base64').length)).toEqual([4, 4, 2]); + expect(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk.Data, 'base64')))).toEqual(bytes); + }); + it('resumes from the count the server reports, not the one the device remembers', async () => { api.getRecordUpload.mockResolvedValue(session(6)); api.uploadRecordChunk.mockResolvedValue(session(9)); diff --git a/src/lib/records/uploads.ts b/src/lib/records/uploads.ts index 0571caea..251cc712 100644 --- a/src/lib/records/uploads.ts +++ b/src/lib/records/uploads.ts @@ -89,12 +89,6 @@ export const fileSize = async (fileUri: string): Promise => { */ const chunkOf = (bytes: Uint8Array, offsetBytes: number, chunkBytes: number): string => Buffer.from(bytes.subarray(offsetBytes, offsetBytes + chunkBytes)).toString('base64'); -// Whole 3-byte groups keep every full chunk free of base64 padding. -const alignChunkSize = (chunkSize: number): number => { - const safe = Math.max(3, Math.min(chunkSize || 0, 3 * 1024 * 1024)); - return safe - (safe % 3); -}; - export interface UploadOptions { onProgress?: (progress: UploadProgress) => void; /** Checked between chunks so a person can stop an upload without killing the screen. */ @@ -147,7 +141,9 @@ export const runUpload = async (pending: PendingUpload, options: UploadOptions = return { ok: false, code: 'no_session', message: 'The server did not open an upload.', uploadId: null }; } - const chunkSize = alignChunkSize(session.ChunkSize); + // The server accepts a chunk only at a multiple of its own chunk size, and only the last may be + // shorter; 512 KiB is not a multiple of 3, so the size is used exactly as the session declares it. + const chunkSize = session.ChunkSize > 0 ? session.ChunkSize : pending.byteSize; const bytes = Buffer.from(await FileSystem.readAsStringAsync(pending.fileUri, { encoding: FileSystem.EncodingType.Base64 }), 'base64'); // The server's count is authoritative: it is the only thing that knows what actually arrived. let sent = session.ReceivedBytes ?? 0; From 508f7aa4924b78743d31def1ef7f07bddc7de00e Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 26 Sep 2026 09:01:55 -0700 Subject: [PATCH 7/8] RU-T53 PR #287 fixes --- src/app/records/[id].tsx | 10 +++- src/app/records/__tests__/[id].test.tsx | 14 +++++ .../__tests__/deployments-store.test.ts | 17 +++++++ src/stores/records/__tests__/store.test.ts | 51 +++++++++++++++++++ src/stores/records/deployments-store.ts | 9 +++- src/stores/records/store.ts | 39 ++++++++++---- 6 files changed, 129 insertions(+), 11 deletions(-) diff --git a/src/app/records/[id].tsx b/src/app/records/[id].tsx index 7b0df901..db063c4e 100644 --- a/src/app/records/[id].tsx +++ b/src/app/records/[id].tsx @@ -112,7 +112,15 @@ export default function RecordScreen() { return null; } discardDraft(clientRecordId); - return fetchRecord(); + const refreshed = await fetchRecord(); + if (refreshed || !result.record) { + return refreshed; + } + // Saved, but the reload failed. The record the server accepted carries the new row version, so a + // further save, a submit or a finalize goes on from it instead of reporting a false conflict. + setRecord(result.record); + setDirty(false); + return result.record; }, [values, entry, stageDraft, pushDraft, discardDraft, fetchRecord, t] ); diff --git a/src/app/records/__tests__/[id].test.tsx b/src/app/records/__tests__/[id].test.tsx index 9eabe138..4fdd8c33 100644 --- a/src/app/records/__tests__/[id].test.tsx +++ b/src/app/records/__tests__/[id].test.tsx @@ -182,6 +182,20 @@ describe('RecordScreen', () => { unmount(); }); + it('goes on from the accepted record when the reload after a save fails', async () => { + store.pushDraft.mockResolvedValue({ ok: true, recordId: 'r1', record: record(5) }); + mockGetRecord.mockReset(); + mockGetRecord.mockResolvedValueOnce({ Data: record(4) } as never).mockRejectedValue(new Error('Network Error')); + + const { unmount } = render(); + fireEvent.press(await screen.findByTestId('record-form-edit')); + fireEvent.press(screen.getByTestId('record-submit')); + + await waitFor(() => expect(store.submitForReview).toHaveBeenCalledWith('r1', 5)); + expect(store.pushDraft).toHaveBeenCalledTimes(1); + unmount(); + }); + it('does not finalize when the unsaved edits could not be saved', async () => { store.pushDraft.mockResolvedValue({ ok: false, conflict: 'etag' }); diff --git a/src/stores/records/__tests__/deployments-store.test.ts b/src/stores/records/__tests__/deployments-store.test.ts index 28f17eea..424b05ea 100644 --- a/src/stores/records/__tests__/deployments-store.test.ts +++ b/src/stores/records/__tests__/deployments-store.test.ts @@ -242,6 +242,23 @@ describe('Deployments store conformance', () => { expect(useDeploymentsStore.getState().lastFetchedOn).toBeNull(); }); + it('does not start the follow-up reads of a run that finishes after sign-out', async () => { + let release!: (value: unknown) => void; + api.runRecordDeploymentConnector.mockReturnValueOnce(new Promise((resolve) => (release = resolve))); + + const running = useDeploymentsStore.getState().runConnector('c1'); + useDeploymentsStore.getState().reset(); + release({ Data: run('run-late') }); + const result = await running; + + expect(result.ok).toBe(true); + expect(api.getRecordDeploymentConnector).not.toHaveBeenCalled(); + expect(api.getRecordDeploymentReconciliation).not.toHaveBeenCalled(); + expect(api.getRecordDeployments).not.toHaveBeenCalled(); + expect(useDeploymentsStore.getState().runs).toEqual({}); + expect(useDeploymentsStore.getState().runningConnectorId).toBeNull(); + }); + it('reset drops everything, including connector rows that must not outlive the session', () => { useDeploymentsStore.setState({ deployments: [deployment('o1', '2026-09-01T00:00:00Z')], connectors: [connector('c1', 'Alpha')], runs: { c1: [run('r1')] }, reconciliation: [], error: 'x', connectorsError: 'y' }); diff --git a/src/stores/records/__tests__/store.test.ts b/src/stores/records/__tests__/store.test.ts index f45a766f..5f956ae9 100644 --- a/src/stores/records/__tests__/store.test.ts +++ b/src/stores/records/__tests__/store.test.ts @@ -356,6 +356,57 @@ describe('Field Records store conformance', () => { expect(state.isSyncing).toBe(false); }); + it('syncs a new context even while a sync for an earlier context is still running', async () => { + let releaseEarlier!: (value: unknown) => void; + const bundle = (scopeStamp: string, recordId: string) => ({ Data: { Ok: true, ResetRequired: false, ScopeStamp: scopeStamp, ServerTimestampMs: 900, Records: [summary(recordId, RmsRecordState.Finalized, '2026-09-02T00:00:00Z')], Tombstones: [], Drafts: [], Assignments: [] } }); + fieldApi.syncFieldRecords.mockReturnValueOnce(new Promise((resolve) => (releaseEarlier = resolve))).mockResolvedValueOnce(bundle('scope-502', 'r502')); + + useRecordsStore.getState().setContext({ CallId: 501 }); + const earlier = useRecordsStore.getState().sync(); + useRecordsStore.getState().setContext({ CallId: 502 }); + await useRecordsStore.getState().sync(); + + expect(fieldApi.syncFieldRecords).toHaveBeenCalledTimes(2); + expect(fieldApi.syncFieldRecords.mock.calls[1][0].Context).toEqual({ CallId: 502 }); + expect(useRecordsStore.getState().recent.map((record) => record.RecordId)).toEqual(['r502']); + expect(useRecordsStore.getState().isSyncing).toBe(false); + + releaseEarlier(bundle('scope-501', 'r501')); + await earlier; + expect(useRecordsStore.getState().recent.map((record) => record.RecordId)).toEqual(['r502']); + expect(useRecordsStore.getState().isSyncing).toBe(false); + }); + + it('keeps loading until the newest catalog request answers', async () => { + let releaseEarlier!: (value: unknown) => void; + let releaseNewer!: (value: unknown) => void; + fieldApi.getFieldRecordsCatalog.mockReturnValueOnce(new Promise((resolve) => (releaseEarlier = resolve))).mockReturnValueOnce(new Promise((resolve) => (releaseNewer = resolve))); + + useRecordsStore.getState().setContext({ CallId: 501 }); + const earlier = useRecordsStore.getState().fetchCatalog(); + useRecordsStore.getState().setContext({ CallId: 502 }); + const newer = useRecordsStore.getState().fetchCatalog(); + + releaseEarlier({ Data: catalogOf('call-501-form') }); + await earlier; + expect(useRecordsStore.getState().isLoading).toBe(true); + + releaseNewer({ Data: catalogOf('call-502-form') }); + await newer; + expect(useRecordsStore.getState().isLoading).toBe(false); + expect(useRecordsStore.getState().catalog?.Definitions.map((entry) => entry.DefinitionKey)).toEqual(['call-502-form']); + }); + + it('hands back the record the server accepted, with its new row version', async () => { + recordsApi.saveRecordDraft.mockResolvedValue({ Data: { RecordId: 'r1', DefinitionVersion: 3, State: RmsRecordState.Draft, RowVersion: 6 } }); + fieldApi.syncFieldRecords.mockResolvedValue({ Data: { Ok: true, ResetRequired: false, ScopeStamp: 'scope-1', ServerTimestampMs: 10, Records: [], Tombstones: [], Drafts: [], Assignments: [] } }); + const draft = { clientRecordId: 'edit-r1-5', recordId: 'r1', definitionKey: 'shift-log', definitionVersion: 3, name: 'Shift log', values: [], rowVersion: 5, updatedOn: '2026-09-06T00:00:00Z' }; + + const result = await useRecordsStore.getState().pushDraft('edit-r1-5', draft); + + expect(result).toMatchObject({ ok: true, recordId: 'r1', record: { RecordId: 'r1', RowVersion: 6 } }); + }); + it('removes a protected draft staged before the catalog loaded once its send fails', async () => { const draft = { clientRecordId: 'draft-early', recordId: null, definitionKey: 'shift-log', definitionVersion: 3, name: 'Shift log', values: [], updatedOn: '2026-09-06T00:00:00Z' }; useRecordsStore.getState().stageDraft(draft); diff --git a/src/stores/records/deployments-store.ts b/src/stores/records/deployments-store.ts index c2b3a652..ec0936df 100644 --- a/src/stores/records/deployments-store.ts +++ b/src/stores/records/deployments-store.ts @@ -198,6 +198,7 @@ export const useDeploymentsStore = create()( return { ok: false, error: 'busy' }; } const commit = sessionSet(set); + const started = sessionGeneration; set({ runningConnectorId: connectorId, connectorsError: null }); try { const response = await runRecordDeploymentConnector(connectorId); @@ -209,8 +210,14 @@ export const useDeploymentsStore = create()( // The run row is the newest entry in that connector's log; then everything it may have // changed is re-read from the server rather than guessed at from the counts. commit({ runs: { ...get().runs, [connectorId]: [run, ...(get().runs[connectorId] ?? []).filter((existing) => existing.Id !== run.Id)] }, runningConnectorId: null }); + const outcome = { ok: run.Outcome === 'ok', run, error: run.Outcome === 'ok' ? undefined : (run.Error ?? run.Outcome) }; + // Signed out while the run was going: the follow-up reads would start in the new session and + // write the previous session's deployments back, so they are not started at all. + if (started !== sessionGeneration) { + return outcome; + } await Promise.all([get().fetchConnector(connectorId), get().fetchReconciliation(connectorId), get().fetchDeployments()]); - return { ok: run.Outcome === 'ok', run, error: run.Outcome === 'ok' ? undefined : (run.Error ?? run.Outcome) }; + return outcome; } catch (error) { logger.error({ message: 'Connector run failed', context: { error, connectorId } }); const message = messageOf(error, 'run_failed'); diff --git a/src/stores/records/store.ts b/src/stores/records/store.ts index d7d7c9c0..a1baa4d3 100644 --- a/src/stores/records/store.ts +++ b/src/stores/records/store.ts @@ -24,6 +24,7 @@ import { type FieldRecordContextInput, type FieldRecordPrefillData, type FieldRecordPreflightData, + type RecordData, type RecordDefinitionSchema, type RecordSummaryData, type RecordValueInput, @@ -63,6 +64,12 @@ let telemetryQueue: FieldRecordTelemetryEvent[] = []; // upload back into the persisted slice, where the next sign-in would pick it up and send it as theirs. let sessionGeneration = 0; +// The newest catalog request and the newest sync. An older request drops its answer once the context has +// moved on, so it must not end the loading state of, or hold back, the request that replaced it. +let catalogRequest = 0; +let syncRequest = 0; +let syncContext: FieldRecordContextInput | null = null; + // The upload driver reaches for the camera roll and the crypto module, so it is loaded only when an // upload actually runs. Importing it here would drag those native modules into every screen that // touches this store, which is most of them. @@ -120,8 +127,11 @@ interface RecordsState { stageDraft: (draft: PendingRecordDraft) => void; discardDraft: (clientRecordId: string) => void; - /** Sends a staged draft, or `draft` itself when it was not staged (a definition that seals values never is). */ - pushDraft: (clientRecordId: string, draft?: PendingRecordDraft) => Promise<{ ok: boolean; recordId?: string; conflict?: FieldRecordConflictKind; error?: string }>; + /** + * Sends a staged draft, or `draft` itself when it was not staged (a definition that seals values never is). + * On success `record` is the record as the server accepted it, carrying the row version to continue from. + */ + pushDraft: (clientRecordId: string, draft?: PendingRecordDraft) => Promise<{ ok: boolean; recordId?: string; record?: RecordData; conflict?: FieldRecordConflictKind; error?: string }>; pushAllDrafts: () => Promise; submitForReview: (recordId: string, rowVersion: number) => Promise<{ ok: boolean; error?: string }>; finalize: (recordId: string, rowVersion: number, attested: boolean) => Promise<{ ok: boolean; error?: string }>; @@ -226,6 +236,7 @@ export const useRecordsStore = create()( // setContext() only replaces the context when it changes (and reset() always does), so an // answer is committed only while the context it was asked for is still the current one. const context = get().context; + const request = ++catalogRequest; set({ isLoading: true }); try { const response = await getFieldRecordsCatalog(catalogInput(context)); @@ -242,20 +253,26 @@ export const useRecordsStore = create()( } return null; } finally { - set({ isLoading: false }); + if (request === catalogRequest) { + set({ isLoading: false }); + } } }, sync: async (options) => { - if (get().isSyncing) { + const context = get().context; + // One sync at a time for a context. One still running for an earlier context drops its answer, + // so it does not hold back the sync for the context now in view. + if (get().isSyncing && syncContext === context) { return; } + const request = ++syncRequest; + syncContext = context; set({ isSyncing: true }); const full = options?.full === true; // A reset answers with no page, so the full re-pull is run after this one releases the // in-flight guard; recursing inside it would be swallowed by that same guard. let resetAndRetry = false; - const context = get().context; try { const response = await syncFieldRecords({ ...catalogInput(context), @@ -317,7 +334,10 @@ export const useRecordsStore = create()( set({ error: messageFrom(error) }); } } finally { - set({ isSyncing: false }); + if (request === syncRequest) { + syncContext = null; + set({ isSyncing: false }); + } } if (resetAndRetry) { @@ -395,8 +415,9 @@ export const useRecordsStore = create()( OriginClient: RECORDS_ORIGIN_CLIENT, }); - const recordId = result?.Data?.RecordId; - if (!recordId) { + const record = result?.Data; + const recordId = record?.RecordId; + if (!record || !recordId) { throw new Error('The server did not return a record.'); } get().discardDraft(clientRecordId); @@ -405,7 +426,7 @@ export const useRecordsStore = create()( get().report({ EventType: 'draft_saved', Outcome: 'ok', RecordId: recordId, DefinitionKey: draft.definitionKey, DefinitionVersion: draft.definitionVersion }); void get().sync(); } - return { ok: true, recordId }; + return { ok: true, recordId, record }; } catch (error) { const conflict = conflictFrom(error); logger.error({ message: 'Record draft push failed', context: { error, clientRecordId, conflict } }); From 6cbedf32acca488967e327b8960e567fdb13ed63 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 26 Sep 2026 09:20:49 -0700 Subject: [PATCH 8/8] RU-T53 PR #287 fixes --- src/stores/records/__tests__/store.test.ts | 72 ++++++++++++++++++---- src/stores/records/store.ts | 18 ++++-- 2 files changed, 75 insertions(+), 15 deletions(-) diff --git a/src/stores/records/__tests__/store.test.ts b/src/stores/records/__tests__/store.test.ts index 5f956ae9..319a490e 100644 --- a/src/stores/records/__tests__/store.test.ts +++ b/src/stores/records/__tests__/store.test.ts @@ -93,22 +93,26 @@ describe('Field Records store conformance', () => { }); it('keeps the sync delta and drops what the caller may no longer read', async () => { - useRecordsStore.setState({ recent: [summary('r1', RmsRecordState.Finalized, '2026-09-01T00:00:00Z'), summary('r9', RmsRecordState.Finalized, '2026-09-01T00:00:00Z')] }); - fieldApi.syncFieldRecords.mockResolvedValue({ - Data: { - Ok: true, - ResetRequired: false, + const page = (overrides: Record) => ({ Data: { Ok: true, ResetRequired: false, Records: [], Tombstones: [], Drafts: [], Assignments: [], ...overrides } }); + // The first sync for a context is a full pull; it is what a later delta builds on. + fieldApi.syncFieldRecords.mockResolvedValueOnce( + page({ ScopeStamp: 'scope-1', ServerTimestampMs: 500, Records: [summary('r1', RmsRecordState.Finalized, '2026-09-01T00:00:00Z'), summary('r9', RmsRecordState.Finalized, '2026-09-01T00:00:00Z')] }) + ); + await useRecordsStore.getState().sync(); + + fieldApi.syncFieldRecords.mockResolvedValueOnce( + page({ ScopeStamp: 'scope-1', ServerTimestampMs: 1000, Records: [summary('r2', RmsRecordState.Finalized, '2026-09-02T00:00:00Z')], Tombstones: ['r9'], Drafts: [summary('d1', RmsRecordState.Draft, '2026-09-02T00:00:00Z')], Assignments: [{ AssignmentId: 'a1', RecordId: 'r2', AssigneeKind: 'Person', Purpose: 'complete', State: 'Open', CreatedOn: '2026-09-02T00:00:00Z', RowVersion: 1 }], - }, - }); - + }) + ); await useRecordsStore.getState().sync(); + expect(fieldApi.syncFieldRecords.mock.calls[1][0]).toMatchObject({ Since: 500, ScopeStamp: 'scope-1' }); const state = useRecordsStore.getState(); expect(state.recent.map((record) => record.RecordId)).toEqual(['r2', 'r1']); expect(state.recent.some((record) => record.RecordId === 'r9')).toBe(false); @@ -119,17 +123,49 @@ describe('Field Records store conformance', () => { }); it('clears the cached working set and re-pulls when the server says the scope changed', async () => { - useRecordsStore.setState({ recent: [summary('r1', RmsRecordState.Finalized, '2026-09-01T00:00:00Z')], lastSyncTimestampMs: 500, scopeStamp: 'scope-0' }); fieldApi.syncFieldRecords + .mockResolvedValueOnce({ + Data: { Ok: true, ResetRequired: false, ScopeStamp: 'scope-0', ServerTimestampMs: 500, Records: [summary('r1', RmsRecordState.Finalized, '2026-09-01T00:00:00Z')], Tombstones: [], Drafts: [], Assignments: [] }, + }) .mockResolvedValueOnce({ Data: { Ok: true, ResetRequired: true, ScopeStamp: 'scope-2', ServerTimestampMs: 0, Records: [], Tombstones: [], Drafts: [], Assignments: [] } }) .mockResolvedValueOnce({ Data: { Ok: true, ResetRequired: false, ScopeStamp: 'scope-2', ServerTimestampMs: 900, Records: [], Tombstones: [], Drafts: [], Assignments: [] } }); + await useRecordsStore.getState().sync(); + expect(useRecordsStore.getState().recent).toHaveLength(1); await useRecordsStore.getState().sync(); expect(useRecordsStore.getState().recent).toEqual([]); expect(useRecordsStore.getState().scopeStamp).toBe('scope-2'); - expect(fieldApi.syncFieldRecords).toHaveBeenCalledTimes(2); - expect(fieldApi.syncFieldRecords.mock.calls[1][0].Since).toBe(0); + expect(fieldApi.syncFieldRecords).toHaveBeenCalledTimes(3); + expect(fieldApi.syncFieldRecords.mock.calls[1][0]).toMatchObject({ Since: 500, ScopeStamp: 'scope-0' }); + expect(fieldApi.syncFieldRecords.mock.calls[2][0]).toMatchObject({ Since: 0, ScopeStamp: null }); + }); + + it('pulls in full and rebuilds the lists for a context it has not synced for', async () => { + const page = (recordId: string, timestamp: number) => ({ + Data: { + Ok: true, + ResetRequired: false, + ScopeStamp: 'scope-1', + ServerTimestampMs: timestamp, + Records: [summary(recordId, RmsRecordState.Finalized, '2026-09-01T00:00:00Z')], + Tombstones: [], + Drafts: [], + Assignments: [], + }, + }); + // A cursor kept from an earlier launch is not a delta base: the lists it was built with are gone. + useRecordsStore.setState({ lastSyncTimestampMs: 400, scopeStamp: 'scope-1' }); + fieldApi.syncFieldRecords.mockResolvedValueOnce(page('r501', 500)).mockResolvedValueOnce(page('r502', 600)); + + useRecordsStore.getState().setContext({ CallId: 501 }); + await useRecordsStore.getState().sync(); + useRecordsStore.getState().setContext({ CallId: 502 }); + await useRecordsStore.getState().sync(); + + expect(fieldApi.syncFieldRecords.mock.calls[0][0]).toMatchObject({ Since: 0, ScopeStamp: null }); + expect(fieldApi.syncFieldRecords.mock.calls[1][0]).toMatchObject({ Since: 0, ScopeStamp: null, Context: { CallId: 502 } }); + expect(useRecordsStore.getState().recent.map((record) => record.RecordId)).toEqual(['r502']); }); it('refuses to stage a protected definition on the device', () => { @@ -377,6 +413,20 @@ describe('Field Records store conformance', () => { expect(useRecordsStore.getState().isSyncing).toBe(false); }); + it('keeps the newest catalog when an older request for the same context answers last', async () => { + let releaseOlder!: (value: unknown) => void; + fieldApi.getFieldRecordsCatalog.mockReturnValueOnce(new Promise((resolve) => (releaseOlder = resolve))).mockResolvedValueOnce({ Data: catalogOf('newer-form') }); + + useRecordsStore.getState().setContext({ CallId: 501 }); + const older = useRecordsStore.getState().fetchCatalog(); + await useRecordsStore.getState().fetchCatalog(); + releaseOlder({ Data: { ...catalogOf('older-form'), ScopeStamp: 'scope-old' } }); + + expect(await older).toBeNull(); + expect(useRecordsStore.getState().catalog?.Definitions.map((entry) => entry.DefinitionKey)).toEqual(['newer-form']); + expect(useRecordsStore.getState().scopeStamp).not.toBe('scope-old'); + }); + it('keeps loading until the newest catalog request answers', async () => { let releaseEarlier!: (value: unknown) => void; let releaseNewer!: (value: unknown) => void; diff --git a/src/stores/records/store.ts b/src/stores/records/store.ts index a1baa4d3..c27299e0 100644 --- a/src/stores/records/store.ts +++ b/src/stores/records/store.ts @@ -70,6 +70,11 @@ let catalogRequest = 0; let syncRequest = 0; let syncContext: FieldRecordContextInput | null = null; +// The context the in-memory synced lists and the delta cursor were built for. The context narrows what +// the server returns, so only a sync for that same context may ask for a delta; any other — another Call, +// a new session, or a fresh launch whose lists start empty — pulls in full and rebuilds the lists. +let syncedContext: FieldRecordContextInput | null = null; + // The upload driver reaches for the camera roll and the crypto module, so it is loaded only when an // upload actually runs. Importing it here would drag those native modules into every screen that // touches this store, which is most of them. @@ -240,7 +245,8 @@ export const useRecordsStore = create()( set({ isLoading: true }); try { const response = await getFieldRecordsCatalog(catalogInput(context)); - if (get().context !== context) { + // A newer request (for this context or another) owns the catalog now. + if (request !== catalogRequest || get().context !== context) { return null; } const catalog = response?.Data ?? null; @@ -248,7 +254,7 @@ export const useRecordsStore = create()( return catalog; } catch (error) { logger.error({ message: 'Field Records catalog failed', context: { error } }); - if (get().context === context) { + if (request === catalogRequest && get().context === context) { set({ error: messageFrom(error) }); } return null; @@ -269,7 +275,9 @@ export const useRecordsStore = create()( const request = ++syncRequest; syncContext = context; set({ isSyncing: true }); - const full = options?.full === true; + // Lists and cursor from another context are neither sent as a delta nor merged into. + const rebuild = syncedContext !== context; + const full = options?.full === true || rebuild; // A reset answers with no page, so the full re-pull is run after this one releases the // in-flight guard; recursing inside it would be swallowed by that same guard. let resetAndRetry = false; @@ -288,6 +296,7 @@ export const useRecordsStore = create()( return; } + syncedContext = context; if (bundle.ResetRequired) { // Scope changed under us: everything cached for the old scope is dropped, including the // catalog. Staged drafts survive — they are this person's unsent work, not server state. @@ -304,7 +313,7 @@ export const useRecordsStore = create()( } else { const tombstoned = new Set(bundle.Tombstones ?? []); const merged = new Map(); - for (const record of get().recent) { + for (const record of rebuild ? [] : get().recent) { if (!tombstoned.has(record.RecordId)) { merged.set(record.RecordId, record); } @@ -636,6 +645,7 @@ export const useRecordsStore = create()( // letting the next session flush another member's counts under their own name. telemetryQueue = []; sessionGeneration += 1; + syncedContext = null; set({ preflight: null, catalog: null,