diff --git a/scripts/extract-release-notes.sh b/scripts/extract-release-notes.sh index 671a68f..a31e874 100755 --- a/scripts/extract-release-notes.sh +++ b/scripts/extract-release-notes.sh @@ -35,6 +35,10 @@ extract_release_notes() { { print } ')" + # Replace standalone occurrences of "PR" with "Release" (word boundaries only, + # so words like "PROXY" or "PRs" inside other tokens are untouched) + cleaned_body="$(printf '%s\n' "$cleaned_body" | sed -E 's/(^|[^[:alnum:]])PR([^[:alnum:]]|$)/\1Release\2/g')" + # Fourth pass: Remove any remaining HTML comment lines cleaned_body="$(printf '%s\n' "$cleaned_body" | sed '/^$/d' | sed '/^$/d')" diff --git a/src/api/incidentCommand/incidentCommand.ts b/src/api/incidentCommand/incidentCommand.ts index d1b386f..0ef3edb 100644 --- a/src/api/incidentCommand/incidentCommand.ts +++ b/src/api/incidentCommand/incidentCommand.ts @@ -31,6 +31,8 @@ import type { ReopenCommandInput, ResourceAssignment, ResourceAssignmentResult, + SendMessageToCommandInput, + SendMessageToCommandResult, SetNeedStatusInput, TacticalObjective, TacticalObjectiveResult, @@ -61,6 +63,12 @@ export const transferCommand = async (input: TransferCommandInput) => { return response.data; }; +/** Sends a free-form message directly to the incident's commander (and optionally deputies) via their notification channels. */ +export const sendMessageToCommand = async (input: SendMessageToCommandInput) => { + const response = await createApiEndpoint('/IncidentCommand/SendMessageToCommand').post({ ...input }); + return response.data; +}; + export const closeCommand = async (incidentCommandId: string) => { const response = await createApiEndpoint(`/IncidentCommand/CloseCommand/${encodeURIComponent(incidentCommandId)}`).put({}); return response.data; diff --git a/src/app/(app)/__tests__/command.test.tsx b/src/app/(app)/__tests__/command.test.tsx index 225f665..2e75490 100644 --- a/src/app/(app)/__tests__/command.test.tsx +++ b/src/app/(app)/__tests__/command.test.tsx @@ -25,6 +25,8 @@ jest.mock('lucide-react-native', () => { MapPin: icon('map-pin'), Mic: icon('mic'), MicOff: icon('mic-off'), + Eye: icon('eye'), + Pencil: icon('pencil'), Phone: icon('phone'), PhoneOff: icon('phone-off'), RadioTower: icon('radio-tower'), @@ -87,6 +89,45 @@ jest.mock('@/components/command/incident-map-card', () => ({ IncidentMapCard: () => null, })); +// The resource details sheet pulls in the Mapping API — stub it with a plain action surface. +jest.mock('@/components/command/resource-details-sheet', () => { + const React = require('react'); + return { + ResourceDetailsSheet: (props: any) => + props.isOpen + ? React.createElement('View', { testID: 'resource-details-sheet' }, [ + React.createElement('Text', { key: 'label', testID: 'resource-details-action-label' }, props.actionLabel), + React.createElement( + 'Text', + { + key: 'action', + testID: props.actionTestID, + onPress: () => { + props.onAction(); + props.onClose(); + }, + }, + props.actionLabel + ), + props.secondaryActionLabel + ? React.createElement( + 'Text', + { + key: 'secondary', + testID: props.secondaryActionTestID, + onPress: () => { + props.onSecondaryAction(); + props.onClose(); + }, + }, + props.secondaryActionLabel + ) + : null, + ]) + : null, + }; +}); + // Call resource viewers pull in webview / keyboard-controller natives — stub them out here. jest.mock('@/components/call-video-feeds/video-feed-tab-content', () => ({ VideoFeedTabContent: () => null, @@ -282,7 +323,7 @@ describe('CommandBoard', () => { unmount(); }); - it('lists tracked department units and personnel as resources with their lane and releases on remove', () => { + it('lists tracked department units and personnel as resources with their lane and releases via the details sheet', () => { const board = serverBoard('101', { Nodes: [{ CommandStructureNodeId: 'lane-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, NodeType: 0, Name: 'Division A', SortOrder: 0 }], Assignments: [ @@ -300,16 +341,84 @@ describe('CommandBoard', () => { users: [{ UserId: 'u-9', FirstName: 'Sam', LastName: 'Jones', GroupName: 'Station 1', Status: 'Responding' }], }); - const { getByTestId, getByText, unmount } = render(); + const { getByTestId, getAllByText, getByText, unmount } = render(); expect(getByTestId('resource-dept-as-1')).toBeTruthy(); expect(getByTestId('resource-dept-as-2')).toBeTruthy(); expect(getByText('Sam Jones')).toBeTruthy(); expect(getByText('Station 1')).toBeTruthy(); expect(getByTestId('resource-dept-as-2-status')).toBeTruthy(); - expect(getByText('command.unassigned')).toBeTruthy(); + // Lane badge text ("Unassigned") also appears on the filter chip — assert at least the badge exists + expect(getAllByText('command.unassigned').length).toBeGreaterThan(0); + + fireEvent.press(getByTestId('resource-dept-view-as-2')); + expect(getByTestId('resource-details-sheet')).toBeTruthy(); + fireEvent.press(getByTestId('resource-details-action')); + expect(mockReleaseResourceAssignment).toHaveBeenCalledWith('101', 'as-2'); + + unmount(); + }); + + it('filters the resources list by unassigned / assigned / all', () => { + const board = serverBoard('101', { + Nodes: [{ CommandStructureNodeId: 'lane-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, NodeType: 0, Name: 'Division A', SortOrder: 0 }], + Assignments: [ + { ResourceAssignmentId: 'as-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, CommandStructureNodeId: 'lane-1', ResourceKind: 0, ResourceId: 'unit-5', AssignedByUserId: 'u1', AssignedOn: '2026-07-19T10:00:00Z', RequirementsWarning: false }, + { ResourceAssignmentId: 'as-2', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, CommandStructureNodeId: '', ResourceKind: 1, ResourceId: 'u-9', AssignedByUserId: 'u1', AssignedOn: '2026-07-19T10:00:00Z', RequirementsWarning: false }, + ], + }) as any; + + setupStores({ + boards: { '101': board }, + activeCallId: '101', + calls: [{ CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }], + users: [{ UserId: 'u-9', FirstName: 'Sam', LastName: 'Jones', GroupName: 'Station 1', Status: 'Responding' }], + }); + + const { getByTestId, queryByTestId, unmount } = render(); + + // All: both visible + expect(getByTestId('resource-dept-as-1')).toBeTruthy(); + expect(getByTestId('resource-dept-as-2')).toBeTruthy(); + + fireEvent.press(getByTestId('resource-filter-unassigned')); + expect(queryByTestId('resource-dept-as-1')).toBeNull(); + expect(getByTestId('resource-dept-as-2')).toBeTruthy(); + + fireEvent.press(getByTestId('resource-filter-assigned')); + expect(getByTestId('resource-dept-as-1')).toBeTruthy(); + expect(queryByTestId('resource-dept-as-2')).toBeNull(); + + fireEvent.press(getByTestId('resource-filter-all')); + expect(getByTestId('resource-dept-as-1')).toBeTruthy(); + expect(getByTestId('resource-dept-as-2')).toBeTruthy(); + + unmount(); + }); + + it('lane items offer both remove-from-lane and release in the details sheet', () => { + const board = serverBoard('101', { + Nodes: [{ CommandStructureNodeId: 'lane-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, NodeType: 0, Name: 'Division A', SortOrder: 0 }], + Assignments: [{ ResourceAssignmentId: 'as-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, CommandStructureNodeId: 'lane-1', ResourceKind: 0, ResourceId: 'unit-5', AssignedByUserId: 'u1', AssignedOn: '2026-07-19T10:00:00Z', RequirementsWarning: false }], + }) as any; + + setupStores({ + boards: { '101': board }, + activeCallId: '101', + calls: [{ CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }], + }); + + const { getByTestId, unmount } = render(); + + fireEvent.press(getByTestId('lane-resource-view-as-1')); + expect(getByTestId('resource-details-sheet')).toBeTruthy(); + + fireEvent.press(getByTestId('resource-details-action')); + expect(mockMoveResourceAssignment).toHaveBeenCalledWith('101', 'as-1', ''); - fireEvent.press(getByTestId('resource-dept-remove-as-1')); + // Reopen — release is the secondary action for lane items + fireEvent.press(getByTestId('lane-resource-view-as-1')); + fireEvent.press(getByTestId('resource-details-secondary-action')); expect(mockReleaseResourceAssignment).toHaveBeenCalledWith('101', 'as-1'); unmount(); diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index a4c8b3c..81bb9fe 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -304,7 +304,7 @@ export default function TabLayout() { } | null>(null); if (novuReady) { lastNovuConfig.current = { - subscriberId: `${rights?.DepartmentCode}_User_${userId}`, + subscriberId: `${rights?.DepartmentCode}_IC_User_${userId}`, applicationIdentifier: config!.NovuApplicationId, backendUrl: config!.NovuBackendApiUrl, socketUrl: config!.NovuSocketUrl, diff --git a/src/app/(app)/command.tsx b/src/app/(app)/command.tsx index 4ddc525..8fe112f 100644 --- a/src/app/(app)/command.tsx +++ b/src/app/(app)/command.tsx @@ -1,5 +1,5 @@ import { router } from 'expo-router'; -import { ClipboardList, CloudOff, ExternalLink, Image as ImageIcon, Info, MapPin, Paperclip, Pencil, RefreshCw, StickyNote, Trash2, UserCog, Video as VideoIcon, XCircle } from 'lucide-react-native'; +import { ClipboardList, CloudOff, ExternalLink, Image as ImageIcon, Info, MapPin, MessageSquare, Paperclip, Pencil, RefreshCw, StickyNote, Trash2, UserCog, Video as VideoIcon, XCircle } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView, useWindowDimensions } from 'react-native'; @@ -16,15 +16,16 @@ import { type AssignableResourceOption, AssignResourceSheet } from '@/components import { CommandDetailsSheet } from '@/components/command/command-details-sheet'; import { CommandSection } from '@/components/command/command-section'; import { IncidentFilesSection } from '@/components/command/incident-files-section'; -import IncidentMapCard from '@/components/command/incident-map-card'; -import { IncidentMapsSection } from '@/components/command/incident-maps-section'; import { IncidentWeatherSection } from '@/components/command/incident-weather-section'; import { LandscapeStructureBoard } from '@/components/command/landscape-structure-board'; import { LaneDetailsSheet } from '@/components/command/lane-details-sheet'; +import { MapsTabbedCard } from '@/components/command/maps-tabbed-card'; +import { MessageCommanderSheet } from '@/components/command/message-commander-sheet'; import { NeedsSection } from '@/components/command/needs-section'; import { NotesSection } from '@/components/command/notes-section'; import { ObjectivesSection } from '@/components/command/objectives-section'; import { PersonnelResourceCard, UnitResourceCard } from '@/components/command/resource-cards'; +import { ResourceDetailsSheet } from '@/components/command/resource-details-sheet'; import { StructureSection } from '@/components/command/structure-section'; import { SceneClock, TimelineSection } from '@/components/command/timeline-section'; import { TimersSection } from '@/components/command/timers-section'; @@ -46,7 +47,7 @@ import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { getIncidentRoleName } from '@/lib/incident-command-utils'; import { isWeb } from '@/lib/platform'; -import { type IncidentNeedStatus, ResourceAssignmentKind } from '@/models/v4/incidentCommand/incidentCommandModels'; +import { type IncidentNeedStatus, IncidentRoleType, type ResourceAssignment, ResourceAssignmentKind } from '@/models/v4/incidentCommand/incidentCommandModels'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; import { type AssignmentOutcome } from '@/stores/command/store'; @@ -96,6 +97,7 @@ export default function CommandBoard() { const startTimer = useCommandStore((state) => state.startTimer); const acknowledgeTimer = useCommandStore((state) => state.acknowledgeTimer); const transferIncidentCommand = useCommandStore((state) => state.transferIncidentCommand); + const sendMessageToCommander = useCommandStore((state) => state.sendMessageToCommander); const fetchTimeline = useCommandStore((state) => state.fetchTimeline); const createVoiceChannel = useCommandStore((state) => state.createVoiceChannel); const fetchVoiceChannels = useCommandStore((state) => state.fetchVoiceChannels); @@ -134,7 +136,12 @@ export default function CommandBoard() { /** Pending "already assigned elsewhere — move it?" confirmation. */ const [moveConflict, setMoveConflict] = useState<{ assignmentId: string; resourceName: string; fromLane: string; toLane: string; targetNodeId: string } | null>(null); const [isTransferSheetOpen, setIsTransferSheetOpen] = useState(false); + const [isMessageSheetOpen, setIsMessageSheetOpen] = useState(false); const [editLaneNodeId, setEditLaneNodeId] = useState(null); + /** Resource being inspected in the details sheet; context decides lane-remove vs pool-release. */ + const [viewResource, setViewResource] = useState<{ assignment: ResourceAssignment; context: 'lane' | 'pool' } | null>(null); + /** Resources list filter: pool-only, lane-only, or everything. */ + const [resourceFilter, setResourceFilter] = useState<'all' | 'unassigned' | 'assigned'>('all'); const [isCommandDetailsOpen, setIsCommandDetailsOpen] = useState(false); const [isEndConfirmOpen, setIsEndConfirmOpen] = useState(false); /** Which call-resource viewer (from the underlying call) is open on top of the board. */ @@ -200,6 +207,11 @@ export default function CommandBoard() { } }, [activeBoardCallId]); + const handleOpenCommandDetails = useCallback(() => setIsCommandDetailsOpen(true), []); + const handleOpenTransfer = useCallback(() => setIsTransferSheetOpen(true), []); + const handleOpenMessage = useCallback(() => setIsMessageSheetOpen(true), []); + const handleOpenEndConfirm = useCallback(() => setIsEndConfirmOpen(true), []); + const handleEndCommand = useCallback(() => { setIsEndConfirmOpen(false); if (activeBoardCallId) { @@ -249,6 +261,18 @@ export default function CommandBoard() { [activeBoardCallId, transferIncidentCommand, showToast, t] ); + const handleSendCommandMessage = useCallback( + async (title: string | null, body: string, includeDeputies: boolean) => { + if (!activeBoardCallId) { + return false; + } + const ok = await sendMessageToCommander(activeBoardCallId, title, body, includeDeputies); + showToast(ok ? 'success' : 'error', ok ? t('command.message_send_success') : t('command.message_send_error')); + return ok; + }, + [activeBoardCallId, sendMessageToCommander, showToast, t] + ); + const handleGoToCalls = useCallback(() => { router.push('/calls'); }, []); @@ -320,6 +344,20 @@ export default function CommandBoard() { const isUnitKind = useCallback((kind: number) => kind === ResourceAssignmentKind.RealUnit || kind === ResourceAssignmentKind.LinkedDeptUnit, []); + const filteredDeptAssignments = useMemo( + () => + deptAssignments.filter((a) => { + if (resourceFilter === 'unassigned') { + return !a.CommandStructureNodeId; + } + if (resourceFilter === 'assigned') { + return !!a.CommandStructureNodeId; + } + return true; + }), + [deptAssignments, resourceFilter] + ); + const trackedUnitIds = useMemo(() => deptAssignments.filter((a) => isUnitKind(a.ResourceKind)).map((a) => a.ResourceId), [deptAssignments, isUnitKind]); const trackedUserIds = useMemo(() => deptAssignments.filter((a) => !isUnitKind(a.ResourceKind)).map((a) => a.ResourceId), [deptAssignments, isUnitKind]); @@ -418,6 +456,50 @@ export default function CommandBoard() { [activeBoardCallId, moveResourceAssignment, notifyAssignmentOutcome] ); + // Lane item action — pull the resource out of its lane back into the unassigned pool + const handleRemoveFromLane = useCallback( + async (assignmentId: string) => { + if (!activeBoardCallId) { + return; + } + const outcome = await moveResourceAssignment(activeBoardCallId, assignmentId, ''); + notifyAssignmentOutcome(outcome); + }, + [activeBoardCallId, moveResourceAssignment, notifyAssignmentOutcome] + ); + + // Pool item action — release the resource off the incident entirely + const handleReleaseFromIncident = useCallback( + async (assignmentId: string) => { + if (!activeBoardCallId) { + return; + } + await releaseResourceAssignment(activeBoardCallId, assignmentId); + }, + [activeBoardCallId, releaseResourceAssignment] + ); + + // Delete a lane — its resources are either moved back to the pool or released first + const handleDeleteLane = useCallback( + async (nodeId: string, disposition: 'pool' | 'release') => { + if (!activeBoardCallId) { + return; + } + const laneAssignments = (boards[activeBoardCallId]?.board?.Assignments ?? []).filter((a) => !a.ReleasedOn && a.CommandStructureNodeId === nodeId); + if (disposition === 'pool') { + const outcomes = await Promise.all(laneAssignments.map((assignment) => moveResourceAssignment(activeBoardCallId, assignment.ResourceAssignmentId, ''))); + outcomes.forEach(notifyAssignmentOutcome); + if (outcomes.some((outcome) => outcome?.blocked)) { + return; + } + } else { + await Promise.all(laneAssignments.map((assignment) => releaseResourceAssignment(activeBoardCallId, assignment.ResourceAssignmentId))); + } + await deleteNode(activeBoardCallId, nodeId); + }, + [activeBoardCallId, boards, moveResourceAssignment, releaseResourceAssignment, deleteNode, notifyAssignmentOutcome] + ); + if (!boardState) { return ( @@ -522,17 +604,27 @@ export default function CommandBoard() { {t('command.view_call')} - - + {/* Icon-only by design; a confirmation dialog guards against accidental taps. */} - @@ -590,20 +682,19 @@ export default function CommandBoard() { ) : null} - {/* Incident tactical map — saved framing, markup, ICP/Staging/Rehab, live incident resources */} + {/* Maps pane — incident map (default) and named tactical maps under tabs */} {boardState.board?.Command ? ( - !a.DeletedOn && !a.IncidentMapId)} /> + !a.DeletedOn && !a.IncidentMapId)} + maps={boardState.board.Maps ?? []} + onCreateMap={(name, description, expiresOn) => saveIncidentMapEntry(boardState.callId, { Name: name, Description: description, ExpiresOn: expiresOn })} + onDeleteMap={(incidentMapId) => deleteIncidentMapEntry(boardState.callId, incidentMapId)} + resolveUserName={personName} + /> ) : null} - {/* Named tactical maps (areas of operation, cleanup zones, ...) */} - saveIncidentMapEntry(boardState.callId, { Name: name, Description: description, ExpiresOn: expiresOn })} - onDelete={(incidentMapId) => deleteIncidentMapEntry(boardState.callId, incidentMapId)} - resolveUserName={personName} - /> - {/* Command structure lanes (Division/Group/Branch/...) with assigned resources */} {isLandscapeBoard ? ( setIsLaneSheetOpen(true)} onAssignResource={(nodeId) => setAssignTargetNodeId(nodeId)} - onDeleteLane={(nodeId) => deleteNode(boardState.callId, nodeId)} + onEditLane={(nodeId) => setEditLaneNodeId(nodeId)} onMoveResource={handleMoveResource} - onReleaseResource={(assignmentId) => releaseResourceAssignment(boardState.callId, assignmentId)} + onViewResource={(assignment) => setViewResource({ assignment, context: 'lane' })} resolveResourceName={resolveResourceName} viewportHeight={viewportHeight} viewportWidth={viewportWidth} @@ -624,10 +715,9 @@ export default function CommandBoard() { nodes={boardState.board?.Nodes ?? []} onAddLane={() => setIsLaneSheetOpen(true)} onAssignResource={(nodeId) => setAssignTargetNodeId(nodeId)} - onDeleteLane={(nodeId) => deleteNode(boardState.callId, nodeId)} onEditLane={(nodeId) => setEditLaneNodeId(nodeId)} onMoveResource={handleMoveResource} - onReleaseResource={(assignmentId) => releaseResourceAssignment(boardState.callId, assignmentId)} + onViewResource={(assignment) => setViewResource({ assignment, context: 'lane' })} resolveLeadName={resolveLeadName} resolveResourceName={resolveResourceName} /> @@ -719,22 +809,41 @@ export default function CommandBoard() { count={deptAssignments.length + boardState.adHocUnits.length + boardState.adHocPersonnel.length} addLabel={t('command.add')} emptyText={t('command.empty_resources')} - onAdd={() => setIsResourceSheetOpen(true)} + onAdd={() => { + // Retry a failed/empty roster load when the user actually needs it + if (units.length === 0) { + fetchUnits(); + } + setIsResourceSheetOpen(true); + }} testID="command-resources-section" > - {deptAssignments.map((assignment) => + {deptAssignments.length > 0 ? ( + + + + + + ) : null} + {filteredDeptAssignments.map((assignment) => isUnitKind(assignment.ResourceKind) ? ( releaseResourceAssignment(boardState.callId, assignment.ResourceAssignmentId)} - removeTestID={`resource-dept-remove-${assignment.ResourceAssignmentId}`} + onView={() => setViewResource({ assignment, context: 'pool' })} roles={unitRoles.filter((role) => role.UnitId === assignment.ResourceId)} status={unitCurrentStatuses.find((s) => s.UnitId === assignment.ResourceId)} testID={`resource-dept-${assignment.ResourceAssignmentId}`} unit={units.find((u) => u.UnitId === assignment.ResourceId)} + viewTestID={`resource-dept-view-${assignment.ResourceAssignmentId}`} /> ) : ( releaseResourceAssignment(boardState.callId, assignment.ResourceAssignmentId)} + onView={() => setViewResource({ assignment, context: 'pool' })} person={users.find((u) => u.UserId === assignment.ResourceId)} - removeTestID={`resource-dept-remove-${assignment.ResourceAssignmentId}`} testID={`resource-dept-${assignment.ResourceAssignmentId}`} + viewTestID={`resource-dept-view-${assignment.ResourceAssignmentId}`} /> ) )} @@ -787,7 +896,7 @@ export default function CommandBoard() { /> {/* Auto-logged, time-stamped incident log */} - fetchTimeline(boardState.callId)} /> + fetchTimeline(boardState.callId)} /> @@ -814,8 +923,38 @@ export default function CommandBoard() { maps={boardState.board?.Maps ?? []} users={users} onSave={(nodeId, patch) => updateNodeDetails(boardState.callId, nodeId, patch)} + resourceCount={(boardState.board?.Assignments ?? []).filter((a) => !a.ReleasedOn && a.CommandStructureNodeId === editLaneNodeId).length} + onDelete={(nodeId, disposition) => void handleDeleteLane(nodeId, disposition)} /> setIsCommandDetailsOpen(false)} command={boardState.board?.Command ?? null} onSave={handleSaveCommandInfo} /> + n.CommandStructureNodeId === viewResource.assignment.CommandStructureNodeId)?.Color ?? null) : null} + laneName={viewResource ? laneName(viewResource.assignment.CommandStructureNodeId) : null} + name={viewResource ? resolveResourceName(viewResource.assignment.ResourceKind, viewResource.assignment.ResourceId) : ''} + onAction={() => { + if (!viewResource) { + return; + } + if (viewResource.context === 'lane') { + void handleRemoveFromLane(viewResource.assignment.ResourceAssignmentId); + } else { + void handleReleaseFromIncident(viewResource.assignment.ResourceAssignmentId); + } + }} + onClose={() => setViewResource(null)} + onSecondaryAction={viewResource?.context === 'lane' ? () => void handleReleaseFromIncident(viewResource.assignment.ResourceAssignmentId) : undefined} + person={viewResource ? users.find((u) => u.UserId === viewResource.assignment.ResourceId) : undefined} + resourceId={viewResource?.assignment.ResourceId ?? ''} + secondaryActionLabel={viewResource?.context === 'lane' ? t('command.release_from_incident') : undefined} + secondaryActionTestID={viewResource?.context === 'lane' ? 'resource-details-secondary-action' : undefined} + status={viewResource ? unitCurrentStatuses.find((s) => s.UnitId === viewResource.assignment.ResourceId) : undefined} + unit={viewResource ? units.find((u) => u.UnitId === viewResource.assignment.ResourceId) : undefined} + unitRoles={viewResource ? unitRoles.filter((role) => role.UnitId === viewResource.assignment.ResourceId) : []} + /> setIsTransferSheetOpen(false)} @@ -823,6 +962,13 @@ export default function CommandBoard() { currentCommanderUserId={boardState.board?.Command?.CurrentCommanderUserId} onTransfer={handleTransferCommand} /> + setIsMessageSheetOpen(false)} + commanderName={boardState.board?.Command?.CurrentCommanderUserId ? personName(boardState.board.Command.CurrentCommanderUserId) : null} + hasDeputies={(boardState.board?.Roles ?? []).some((r) => r.RoleType === IncidentRoleType.DeputyIncidentCommander && !r.RemovedOn)} + onSend={handleSendCommandMessage} + /> setAssignTargetNodeId(null)} diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index 1d7ef15..0df1657 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -1,6 +1,6 @@ import { format, isValid } from 'date-fns'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; -import { ClockIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, PaperclipIcon, RouteIcon, TimerIcon, UserIcon, UsersIcon, VideoIcon } from 'lucide-react-native'; +import { ClockIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, MessageSquareIcon, PaperclipIcon, RouteIcon, TimerIcon, UserIcon, UsersIcon, VideoIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -9,6 +9,7 @@ import { ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native' import { getCommandForCall } from '@/api/incidentCommand/incidentCommand'; import { VideoFeedTabContent } from '@/components/call-video-feeds/video-feed-tab-content'; import { CheckInTabContent } from '@/components/check-in-timers/check-in-tab-content'; +import { MessageCommanderSheet } from '@/components/command/message-commander-sheet'; import { ReopenCommandSheet } from '@/components/command/reopen-command-sheet'; import { StartCommandSheet } from '@/components/command/start-command-sheet'; import { Loading } from '@/components/common/loading'; @@ -27,7 +28,7 @@ import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; import { logger } from '@/lib/logging'; import { openMapsWithDirections } from '@/lib/navigation'; -import { type IncidentCommand } from '@/models/v4/incidentCommand/incidentCommandModels'; +import { type IncidentCommand, IncidentRoleType } from '@/models/v4/incidentCommand/incidentCommandModels'; import { useLocationStore } from '@/stores/app/location-store'; import { useCallDetailStore } from '@/stores/calls/detail-store'; import { useCheckInTimerStore } from '@/stores/check-in-timers/store'; @@ -66,6 +67,9 @@ export default function CallDetail() { const canUserCreateCalls = securityStore((state) => state.rights?.CanCreateCalls); const activeBoardCallId = useCommandStore((state) => state.activeCallId); const hasCommandBoard = useCommandStore((state) => !!(callId && state.boards[callId])); + const commandBoard = useCommandStore((state) => (callId ? state.boards[callId]?.board : undefined)); + const commanderUserId = commandBoard?.Command?.CurrentCommanderUserId ?? null; + const [isMessageSheetOpen, setIsMessageSheetOpen] = useState(false); const [isNotesModalOpen, setIsNotesModalOpen] = useState(false); const [isImagesModalOpen, setIsImagesModalOpen] = useState(false); const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); @@ -169,6 +173,16 @@ export default function CallDetail() { [call, priorCommandPrompt, showToast, t, router] ); + const handleSendCommandMessage = useCallback( + async (title: string | null, body: string, includeDeputies: boolean) => { + if (!call) return false; + const ok = await useCommandStore.getState().sendMessageToCommander(call.CallId, title, body, includeDeputies); + showToast(ok ? 'success' : 'error', ok ? t('command.message_send_success') : t('command.message_send_error')); + return ok; + }, + [call, showToast, t] + ); + // Initialize the call detail menu hook const { HeaderRightMenu, CallDetailActionSheet } = useCallDetailMenu({ onEditCall: handleEditCall, @@ -531,6 +545,11 @@ export default function CallDetail() { {t('command.active_badge')} ) : null} + {commanderUserId ? ( + + ) : null} + + + + + {isSearchOpen ? ( + + + + + + ) : null} + + entry.CommandLogEntryId} + ListEmptyComponent={ + + {search.trim() ? t('command.log_no_results') : t('command.empty_timeline')} + + } + renderItem={({ item: entry }) => ( + + {formatTimestamp(entry.OccurredOn)} + {entry.Description} + + )} + /> + + ); +} diff --git a/src/app/resource-map/index.tsx b/src/app/resource-map/index.tsx new file mode 100644 index 0000000..fbb5f7b --- /dev/null +++ b/src/app/resource-map/index.tsx @@ -0,0 +1,84 @@ +import { Stack, useLocalSearchParams } from 'expo-router'; +import { useColorScheme } from 'nativewind'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import Mapbox from '@/components/maps/mapbox'; +import { Box } from '@/components/ui/box'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { Text } from '@/components/ui/text'; +import { Env } from '@/lib/env'; + +Mapbox.setAccessToken(Env.IC_MAPBOX_PUBKEY); + +/** Fullscreen single-resource map: one marker at the resource's last known position. */ +export default function ResourceMapScreen() { + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + const params = useLocalSearchParams<{ latitude?: string; longitude?: string; title?: string; color?: string }>(); + + const first = (value?: string | string[]) => (Array.isArray(value) ? value[0] : value); + const latitude = parseFloat(first(params.latitude) ?? ''); + const longitude = parseFloat(first(params.longitude) ?? ''); + const title = first(params.title) ?? ''; + const rawColor = first(params.color); + const color = rawColor && /^#[0-9a-fA-F]{3,8}$/.test(rawColor) ? rawColor : '#E53E3E'; + + const isValid = Number.isFinite(latitude) && Number.isFinite(longitude) && latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180 && (latitude !== 0 || longitude !== 0); + + if (!isValid) { + return ( + + + + {t('command.resource_position_unknown')} + + ); + } + + return ( + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + map: { + flex: 1, + }, + markerContainer: { + alignItems: 'center', + justifyContent: 'center', + width: 30, + height: 40, + }, + markerPin: { + width: 24, + height: 24, + borderRadius: 12, + borderWidth: 3, + borderColor: '#FFFFFF', + }, + markerDot: { + width: 0, + height: 0, + borderLeftWidth: 6, + borderRightWidth: 6, + borderTopWidth: 8, + borderLeftColor: 'transparent', + borderRightColor: 'transparent', + marginTop: -2, + }, +}); diff --git a/src/components/calls/call-card.tsx b/src/components/calls/call-card.tsx index 12a7c30..6698b63 100644 --- a/src/components/calls/call-card.tsx +++ b/src/components/calls/call-card.tsx @@ -164,7 +164,7 @@ export const CallCard: React.FC = ({ call, priority, showTimerIco } return ( - + {typeLetter} diff --git a/src/components/command/__tests__/lane-details-sheet.test.tsx b/src/components/command/__tests__/lane-details-sheet.test.tsx new file mode 100644 index 0000000..4c4ffe0 --- /dev/null +++ b/src/components/command/__tests__/lane-details-sheet.test.tsx @@ -0,0 +1,79 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => (params ? `${key}:${Object.values(params).join('/')}` : key), + }), +})); + +import type { CommandStructureNode } from '@/models/v4/incidentCommand/incidentCommandModels'; + +import { LaneDetailsSheet } from '../lane-details-sheet'; + +const node = { CommandStructureNodeId: 'lane-1', Name: 'Division A', NodeType: 0, SortOrder: 0 } as CommandStructureNode; + +const renderSheet = (props: Partial> = {}) => render(); + +describe('LaneDetailsSheet delete flow', () => { + it('hides the delete button when no onDelete handler is provided', () => { + const { queryByTestId, unmount } = renderSheet(); + expect(queryByTestId('lane-details-delete')).toBeNull(); + unmount(); + }); + + it('confirms an empty-lane delete directly', () => { + const onDelete = jest.fn(); + const onClose = jest.fn(); + const { getByTestId, unmount } = renderSheet({ onDelete, onClose, resourceCount: 0 }); + + fireEvent.press(getByTestId('lane-details-delete')); + expect(getByTestId('lane-delete-confirm')).toBeTruthy(); + + fireEvent.press(getByTestId('lane-delete-confirm-button')); + expect(onDelete).toHaveBeenCalledWith('lane-1', 'release'); + expect(onClose).toHaveBeenCalled(); + + unmount(); + }); + + it('offers move-to-pool or release when the lane has resources', () => { + const onDelete = jest.fn(); + const onClose = jest.fn(); + const { getByTestId, getByText, unmount } = renderSheet({ onDelete, onClose, resourceCount: 3 }); + + fireEvent.press(getByTestId('lane-details-delete')); + expect(getByText('command.delete_lane_resources_message:3')).toBeTruthy(); + + fireEvent.press(getByTestId('lane-delete-move-resources')); + expect(onDelete).toHaveBeenCalledWith('lane-1', 'pool'); + expect(onClose).toHaveBeenCalled(); + + unmount(); + }); + + it('releases resources when that option is chosen', () => { + const onDelete = jest.fn(); + const { getByTestId, unmount } = renderSheet({ onDelete, onClose: jest.fn(), resourceCount: 2 }); + + fireEvent.press(getByTestId('lane-details-delete')); + fireEvent.press(getByTestId('lane-delete-release-resources')); + expect(onDelete).toHaveBeenCalledWith('lane-1', 'release'); + + unmount(); + }); + + it('cancel returns to the edit form without deleting', () => { + const onDelete = jest.fn(); + const { getByTestId, queryByTestId, unmount } = renderSheet({ onDelete, resourceCount: 1 }); + + fireEvent.press(getByTestId('lane-details-delete')); + fireEvent.press(getByTestId('lane-delete-cancel')); + + expect(queryByTestId('lane-delete-confirm')).toBeNull(); + expect(getByTestId('lane-details-save')).toBeTruthy(); + expect(onDelete).not.toHaveBeenCalled(); + + unmount(); + }); +}); diff --git a/src/components/command/__tests__/message-commander-sheet.test.tsx b/src/components/command/__tests__/message-commander-sheet.test.tsx new file mode 100644 index 0000000..ec7d4e2 --- /dev/null +++ b/src/components/command/__tests__/message-commander-sheet.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +jest.mock('@/components/ui/bottom-sheet', () => ({ + CustomBottomSheet: ({ children, isOpen }: any) => (isOpen ? children : null), +})); + +import { MessageCommanderSheet } from '../message-commander-sheet'; + +describe('MessageCommanderSheet', () => { + it('requires a body before sending and passes trimmed values through', async () => { + const onSend = jest.fn().mockResolvedValue(true); + const onClose = jest.fn(); + const { getByTestId, unmount } = render(); + + // Empty body — send stays disabled + fireEvent.press(getByTestId('message-commander-send')); + expect(onSend).not.toHaveBeenCalled(); + + fireEvent.changeText(getByTestId('message-commander-subject'), ' Update '); + fireEvent.changeText(getByTestId('message-commander-body'), ' Need a PAR check '); + fireEvent(getByTestId('message-commander-deputies'), 'onValueChange', true); + fireEvent.press(getByTestId('message-commander-send')); + + await waitFor(() => expect(onSend).toHaveBeenCalledWith('Update', 'Need a PAR check', true)); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + + unmount(); + }); + + it('sends a null title when the subject is blank and hides the deputies toggle when none are assigned', async () => { + const onSend = jest.fn().mockResolvedValue(true); + const { getByTestId, queryByTestId, unmount } = render(); + + expect(queryByTestId('message-commander-deputies')).toBeNull(); + + fireEvent.changeText(getByTestId('message-commander-body'), 'Status?'); + fireEvent.press(getByTestId('message-commander-send')); + + await waitFor(() => expect(onSend).toHaveBeenCalledWith(null, 'Status?', false)); + + unmount(); + }); + + it('stays open when the send fails', async () => { + const onSend = jest.fn().mockResolvedValue(false); + const onClose = jest.fn(); + const { getByTestId, unmount } = render(); + + fireEvent.changeText(getByTestId('message-commander-body'), 'Status?'); + fireEvent.press(getByTestId('message-commander-send')); + + await waitFor(() => expect(onSend).toHaveBeenCalled()); + expect(onClose).not.toHaveBeenCalled(); + + unmount(); + }); +}); diff --git a/src/components/command/__tests__/resource-cards.test.tsx b/src/components/command/__tests__/resource-cards.test.tsx index a285aff..5e38e30 100644 --- a/src/components/command/__tests__/resource-cards.test.tsx +++ b/src/components/command/__tests__/resource-cards.test.tsx @@ -12,8 +12,8 @@ jest.mock('lucide-react-native', () => { const icon = (name: string) => (props: any) => React.createElement('View', { ...props, testID: `mock-${name}-icon` }); return { CloudOff: icon('cloud-off'), + Eye: icon('eye'), MapPin: icon('map-pin'), - Trash2: icon('trash'), Truck: icon('truck'), }; }); @@ -49,10 +49,8 @@ const person = { describe('UnitResourceCard', () => { it('shows roster info, live status, destination, and role seats with assignees', () => { - const onRelease = jest.fn(); - const { getByText, getByTestId, unmount } = render( - - ); + const onView = jest.fn(); + const { getByText, getByTestId, unmount } = render(); expect(getByText('Engine 1')).toBeTruthy(); expect(getByText('Engine • Station 1')).toBeTruthy(); @@ -65,14 +63,14 @@ describe('UnitResourceCard', () => { expect(getByText('command.role_open')).toBeTruthy(); expect(getByText('Division A')).toBeTruthy(); - fireEvent.press(getByTestId('remove-1')); - expect(onRelease).toHaveBeenCalled(); + fireEvent.press(getByTestId('view-1')); + expect(onView).toHaveBeenCalled(); unmount(); }); it('renders without roster data using just the resolved name', () => { - const { getByText, queryByTestId, unmount } = render(); + const { getByText, queryByTestId, unmount } = render(); expect(getByText('unit-77')).toBeTruthy(); expect(queryByTestId('card-2-status')).toBeNull(); @@ -85,9 +83,7 @@ describe('UnitResourceCard', () => { describe('PersonnelResourceCard', () => { it('shows group, id number, status, staffing, and department role chips', () => { - const { getByText, getByTestId, unmount } = render( - - ); + const { getByText, getByTestId, unmount } = render(); expect(getByText('Alex Reed')).toBeTruthy(); expect(getByText('AR')).toBeTruthy(); diff --git a/src/components/command/__tests__/resource-details-sheet.test.tsx b/src/components/command/__tests__/resource-details-sheet.test.tsx new file mode 100644 index 0000000..e0150bd --- /dev/null +++ b/src/components/command/__tests__/resource-details-sheet.test.tsx @@ -0,0 +1,174 @@ +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => (params ? `${key}:${Object.values(params).join('/')}` : key), + }), +})); + +jest.mock('lucide-react-native', () => { + const React = require('react'); + const icon = (name: string) => (props: any) => React.createElement('View', { ...props, testID: `mock-${name}-icon` }); + return { + Mail: icon('mail'), + Map: icon('map'), + MapPin: icon('map-pin'), + Phone: icon('phone'), + Truck: icon('truck'), + User: icon('user'), + }; +}); + +const mockPush = jest.fn(); +jest.mock('expo-router', () => ({ + router: { push: (...args: unknown[]) => mockPush(...args) }, +})); + +const mockGetMapDataAndMarkers = jest.fn(); +jest.mock('@/api/mapping/mapping', () => ({ + getMapDataAndMarkers: (...args: unknown[]) => mockGetMapDataAndMarkers(...args), +})); + +import type { PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; +import type { ActiveUnitRoleResultData } from '@/models/v4/unitRoles/activeUnitRoleResultData'; +import type { UnitResultData } from '@/models/v4/units/unitResultData'; +import type { UnitStatusResultData } from '@/models/v4/unitStatus/unitStatusResultData'; + +import { ResourceDetailsSheet } from '../resource-details-sheet'; + +const unit = { UnitId: 'unit-1', Name: 'Engine 1', Type: 'Engine', GroupName: 'Station 1' } as UnitResultData; + +const status = { + UnitId: 'unit-1', + State: 'Responding', + StateStyle: '#d35400', + DestinationName: 'Staging', + Eta: '5 min', + Note: 'En route', + Latitude: 47.6205, + Longitude: -122.3493, + Timestamp: '2026-07-19T10:00:00Z', +} as UnitStatusResultData; + +const unitRoles = [ + { UnitId: 'unit-1', UnitRoleId: 'r-1', Name: 'Driver', UserId: 'u-1', FullName: 'Sam Jones', UpdatedOn: '' }, + { UnitId: 'unit-1', UnitRoleId: 'r-2', Name: 'Officer', UserId: '', FullName: '', UpdatedOn: '' }, +] as ActiveUnitRoleResultData[]; + +const person = { + UserId: 'u-9', + FirstName: 'Alex', + LastName: 'Reed', + GroupName: 'Station 2', + IdentificationNumber: 'ID-42', + EmailAddress: 'alex@example.com', + MobilePhone: '555-0100', + Status: 'Available', + StatusColor: '#27ae60', + Staffing: 'On Shift', + StaffingColor: '#2980b9', + Roles: ['Medic', 'Driver'], +} as PersonnelInfoResultData; + +describe('ResourceDetailsSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetMapDataAndMarkers.mockResolvedValue({ Data: { MapMakerInfos: [{ Id: 'pu-9', Latitude: 47.6, Longitude: -122.3 }] } }); + }); + + it('shows unit roster, status, crew, and position; fires the action and closes', async () => { + const onAction = jest.fn(); + const onClose = jest.fn(); + const { getByText, getByTestId, unmount } = render( + + ); + + expect(getByText('command.unit_details_title')).toBeTruthy(); + expect(getByText('Engine 1')).toBeTruthy(); + expect(getByText('Engine • Station 1')).toBeTruthy(); + expect(getByText('Responding')).toBeTruthy(); + expect(getByText('En route')).toBeTruthy(); + expect(getByText('Driver')).toBeTruthy(); + expect(getByText('Sam Jones')).toBeTruthy(); + expect(getByText('command.role_open')).toBeTruthy(); + + // Unit status coordinates render without waiting on the marker fetch + await waitFor(() => expect(getByTestId('resource-details-coords')).toBeTruthy()); + expect(getByText('47.62050, -122.34930')).toBeTruthy(); + + fireEvent.press(getByTestId('details-action')); + expect(onAction).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + + unmount(); + }); + + it('shows person info, status/staffing, roles, and marker-based position', async () => { + const { getByText, getByTestId, unmount } = render( + + ); + + expect(getByText('command.person_details_title')).toBeTruthy(); + expect(getByText('Alex Reed')).toBeTruthy(); + expect(getByText('Station 2 • ID-42')).toBeTruthy(); + expect(getByText('alex@example.com')).toBeTruthy(); + expect(getByText('555-0100')).toBeTruthy(); + expect(getByText('Available')).toBeTruthy(); + expect(getByTestId('resource-details-staffing')).toBeTruthy(); + expect(getByText('Medic')).toBeTruthy(); + expect(getByText('Driver')).toBeTruthy(); + + // Personnel position comes from the shared map markers (p{userId}) + await waitFor(() => expect(getByTestId('resource-details-coords')).toBeTruthy()); + expect(getByText('47.60000, -122.30000')).toBeTruthy(); + + unmount(); + }); + + it('shows the unknown-position fallback when no coordinates exist', async () => { + mockGetMapDataAndMarkers.mockResolvedValue({ Data: { MapMakerInfos: [] } }); + const { getByText, queryByTestId, unmount } = render( + + ); + + await waitFor(() => expect(getByText('command.resource_position_unknown')).toBeTruthy()); + expect(queryByTestId('resource-details-coords')).toBeNull(); + + unmount(); + }); + + it('shows the lane name tinted with the lane color', () => { + const { getByTestId, getByText, unmount } = render( + + ); + + const laneName = getByTestId('resource-details-lane-name'); + expect(getByText('Division A')).toBeTruthy(); + expect(laneName.props.style).toEqual({ color: '#e74c3c' }); + + unmount(); + }); + + it('closes the sheet and navigates to the fullscreen resource map from the position row', async () => { + jest.useFakeTimers(); + const onClose = jest.fn(); + const { getByTestId, unmount } = render( + + ); + + fireEvent.press(getByTestId('resource-details-view-map')); + expect(onClose).toHaveBeenCalled(); + + jest.advanceTimersByTime(350); + expect(mockPush).toHaveBeenCalledWith( + expect.objectContaining({ + pathname: '/resource-map', + params: expect.objectContaining({ latitude: '47.6205', longitude: '-122.3493', title: 'Engine 1' }), + }) + ); + + jest.useRealTimers(); + unmount(); + }); +}); diff --git a/src/components/command/__tests__/timeline-section.test.tsx b/src/components/command/__tests__/timeline-section.test.tsx index 7ccaf4b..4287fda 100644 --- a/src/components/command/__tests__/timeline-section.test.tsx +++ b/src/components/command/__tests__/timeline-section.test.tsx @@ -14,6 +14,11 @@ jest.mock('lucide-react-native', () => { }; }); +const mockPush = jest.fn(); +jest.mock('expo-router', () => ({ + router: { push: (...args: unknown[]) => mockPush(...args) }, +})); + import type { CommandLogEntry } from '@/models/v4/incidentCommand/incidentCommandModels'; import { SceneClock, TimelineSection } from '../timeline-section'; @@ -41,16 +46,30 @@ describe('TimelineSection', () => { unmount(); }); - it('shows the empty state and paginates long logs', () => { + it('shows the empty state and caps the preview at the first batch', () => { const many = Array.from({ length: 20 }, (_, i) => entry(`e-${i}`, `Entry ${i}`)); - const { getByTestId, getByText, queryByText, rerender, unmount } = render(); + const { getByText, queryByText, queryByTestId, rerender, unmount } = render(); expect(getByText('command.empty_timeline')).toBeTruthy(); rerender(); - expect(queryByText('Entry 16')).toBeNull(); - fireEvent.press(getByTestId('command-timeline-more')); - expect(getByText('Entry 16')).toBeTruthy(); + expect(queryByText('Entry 14')).toBeTruthy(); + expect(queryByText('Entry 15')).toBeNull(); + // No callId — history screens render the preview only, no fullscreen link + expect(queryByTestId('command-timeline-view-log')).toBeNull(); + + unmount(); + }); + + it('links to the fullscreen log when a callId is set and entries overflow the preview', () => { + const many = Array.from({ length: 20 }, (_, i) => entry(`e-${i}`, `Entry ${i}`)); + const { getByTestId, queryByTestId, rerender, unmount } = render(); + + expect(queryByTestId('command-timeline-view-log')).toBeNull(); + + rerender(); + fireEvent.press(getByTestId('command-timeline-view-log')); + expect(mockPush).toHaveBeenCalledWith('/command-log/101'); unmount(); }); diff --git a/src/components/command/incident-map-card.tsx b/src/components/command/incident-map-card.tsx index 84c0ecd..052b056 100644 --- a/src/components/command/incident-map-card.tsx +++ b/src/components/command/incident-map-card.tsx @@ -26,6 +26,8 @@ interface IncidentMapCardProps { callId: string; command: IncidentCommand; annotations: IncidentMapAnnotation[]; + /** Render without the outer card + title header (hosted inside a tabbed pane). */ + embedded?: boolean; } /** @@ -33,7 +35,7 @@ interface IncidentMapCardProps { * ICP/Staging/Rehab markers, and live positions of ONLY the units/personnel on this incident. * Tapping anywhere opens the fullscreen editable tactical map. */ -export const IncidentMapCard: React.FC = ({ callId, command, annotations }) => { +export const IncidentMapCard: React.FC = ({ callId, command, annotations, embedded = false }) => { const { t } = useTranslation(); const [pins, setPins] = useState([]); const commandOverlay = useCommandMapOverlay(); @@ -72,6 +74,17 @@ export const IncidentMapCard: React.FC = ({ callId, comman const openFullscreen = () => router.push(`/command-map/${callId}` as never); if (!hasSavedView) { + if (embedded) { + return ( + + {t('command.incident_map_empty_description')} + + + ); + } + return ( @@ -88,6 +101,30 @@ export const IncidentMapCard: React.FC = ({ callId, comman ); } + const mapPreview = ( + + + + + + + + + + + ); + + if (embedded) { + return ( + + + + + {mapPreview} + + ); + } + return ( @@ -98,16 +135,7 @@ export const IncidentMapCard: React.FC = ({ callId, comman - - - - - - - - - - + {mapPreview} ); }; @@ -120,6 +148,10 @@ const styles = StyleSheet.create({ height: 220, width: '100%', }, + mapContainerEmbedded: { + borderRadius: 12, + overflow: 'hidden', + }, }); export default IncidentMapCard; diff --git a/src/components/command/incident-maps-section.tsx b/src/components/command/incident-maps-section.tsx index 718b44d..cb95413 100644 --- a/src/components/command/incident-maps-section.tsx +++ b/src/components/command/incident-maps-section.tsx @@ -28,6 +28,8 @@ interface IncidentMapsSectionProps { onDelete: (incidentMapId: string) => void; /** Resolve a user id to a display name for the audit line. */ resolveUserName?: (userId: string) => string; + /** Render without the outer card + title header (hosted inside a tabbed pane). */ + embedded?: boolean; } export const isMapExpired = (map: IncidentMap): boolean => Boolean(map.ExpiresOn && new Date(map.ExpiresOn).getTime() < Date.now()); @@ -36,7 +38,7 @@ export const isMapExpired = (map: IncidentMap): boolean => Boolean(map.ExpiresOn * Named tactical maps for the incident — each with its own framing, markup, description, optional * expiry, and audit trail. Tapping a map opens it in the fullscreen editor. */ -export const IncidentMapsSection: React.FC = ({ callId, maps, onCreate, onDelete, resolveUserName }) => { +export const IncidentMapsSection: React.FC = ({ callId, maps, onCreate, onDelete, resolveUserName, embedded = false }) => { const { t } = useTranslation(); const [isAddOpen, setIsAddOpen] = useState(false); const [name, setName] = useState(''); @@ -69,18 +71,27 @@ export const IncidentMapsSection: React.FC = ({ callId ); return ( - - - - - {t('command.incident_maps_section')} - ({maps.length}) + + {embedded ? ( + + - - + ) : ( + + + + {t('command.incident_maps_section')} + ({maps.length}) + + + + )} {maps.length === 0 ? ( {t('command.incident_maps_empty')} diff --git a/src/components/command/landscape-structure-board.tsx b/src/components/command/landscape-structure-board.tsx index 931d521..44260d3 100644 --- a/src/components/command/landscape-structure-board.tsx +++ b/src/components/command/landscape-structure-board.tsx @@ -1,4 +1,4 @@ -import { CloudOff, GripVertical, Plus, Trash2, UserPlus } from 'lucide-react-native'; +import { CloudOff, Eye, GripVertical, Pencil, Plus, UserPlus } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Animated, PanResponder, ScrollView, View as NativeView } from 'react-native'; @@ -78,10 +78,12 @@ interface LandscapeStructureBoardProps { viewportWidth: number; resolveResourceName: (kind: number, resourceId: string) => string; onAddLane: () => void; - onDeleteLane: (nodeId: string) => void; + /** Open the lane details editor (leads, linked objectives/need, delete). */ + onEditLane?: (nodeId: string) => void; onAssignResource: (nodeId: string) => void; onMoveResource: (assignmentId: string, targetNodeId: string) => void | Promise; - onReleaseResource: (assignmentId: string) => void; + /** Opens the resource details sheet for a lane assignment (hosts remove-from-lane). */ + onViewResource: (assignment: ResourceAssignment) => void; } interface DraggableResourceCardProps { @@ -94,7 +96,7 @@ interface DraggableResourceCardProps { onDragStart: (assignmentId: string) => void; onDragEnd: () => void; onDrop: (assignmentId: string, pageX: number, pageY: number) => void; - onRelease: (assignmentId: string) => void; + onView: (assignment: ResourceAssignment) => void; } interface LaneRect { @@ -105,7 +107,7 @@ interface LaneRect { height: number; } -const DraggableResourceCard: React.FC = React.memo(({ assignment, name, rotationAfterMinutes, isSelected, onSelect, onDragStart, onDragEnd, onDrop, onRelease }) => { +const DraggableResourceCard: React.FC = React.memo(({ assignment, name, rotationAfterMinutes, isSelected, onSelect, onDragStart, onDragEnd, onDrop, onView }) => { const { t } = useTranslation(); const translation = useRef(new Animated.ValueXY()).current; const dragReadyRef = useRef(false); @@ -172,7 +174,7 @@ const DraggableResourceCard: React.FC = React.memo(( } }, [resetDrag]); - const handleRelease = useCallback(() => onRelease(assignment.ResourceAssignmentId), [assignment.ResourceAssignmentId, onRelease]); + const handleView = useCallback(() => onView(assignment), [assignment, onView]); return ( = React.memo(( - - + + {assignment.ResourceAssignmentId.startsWith('local-') || assignment.RequirementsWarning || isSelected ? ( @@ -236,10 +238,10 @@ export const LandscapeStructureBoard: React.FC = ( viewportWidth, resolveResourceName, onAddLane, - onDeleteLane, + onEditLane, onAssignResource, onMoveResource, - onReleaseResource, + onViewResource, }) => { const { t } = useTranslation(); const [selectedAssignmentId, setSelectedAssignmentId] = useState(null); @@ -364,6 +366,17 @@ export const LandscapeStructureBoard: React.FC = ( ) : null} {node.CommandStructureNodeId.startsWith('local-') ? : null} + {onEditLane ? ( + onEditLane(node.CommandStructureNodeId)} + testID={`landscape-lane-edit-${node.CommandStructureNodeId}`} + > + + + ) : null} = ( > - onDeleteLane(node.CommandStructureNodeId)} - testID={`landscape-lane-delete-${node.CommandStructureNodeId}`} - > - - @@ -399,7 +403,7 @@ export const LandscapeStructureBoard: React.FC = ( onDragEnd={() => setDraggingAssignmentId(null)} onDragStart={setDraggingAssignmentId} onDrop={(assignmentId, pageX, pageY) => void handleDrop(assignmentId, pageX, pageY)} - onRelease={onReleaseResource} + onView={onViewResource} onSelect={handleSelect} /> ))} diff --git a/src/components/command/lane-details-sheet.tsx b/src/components/command/lane-details-sheet.tsx index 135f871..1d905fe 100644 --- a/src/components/command/lane-details-sheet.tsx +++ b/src/components/command/lane-details-sheet.tsx @@ -40,10 +40,14 @@ interface LaneDetailsSheetProps { users: PersonnelInfoResultData[]; /** Persist the edited lane fields (merged into the stored lane by the caller). */ onSave: (commandStructureNodeId: string, patch: Partial) => void; + /** Active resources sitting in this lane — drives the delete confirmation options. */ + resourceCount?: number; + /** Delete the lane; disposition decides what happens to its resources first. */ + onDelete?: (commandStructureNodeId: string, disposition: 'pool' | 'release') => void; } /** Edit an existing lane: leads (primary/secondary — Resgrid user or external contact) and linked objectives/need. */ -export const LaneDetailsSheet: React.FC = ({ isOpen, onClose, node, objectives, needs, maps, users, onSave }) => { +export const LaneDetailsSheet: React.FC = ({ isOpen, onClose, node, objectives, needs, maps, users, onSave, resourceCount = 0, onDelete }) => { const { t } = useTranslation(); const [primaryLead, setPrimaryLead] = useState(emptyLead); const [secondaryLead, setSecondaryLead] = useState(emptyLead); @@ -51,6 +55,8 @@ export const LaneDetailsSheet: React.FC = ({ isOpen, onCl const [secondaryObjectiveId, setSecondaryObjectiveId] = useState(null); const [linkedNeedId, setLinkedNeedId] = useState(null); const [linkedMapId, setLinkedMapId] = useState(null); + /** Two-step delete: swaps the sheet body for the confirmation view. */ + const [isConfirmingDelete, setIsConfirmingDelete] = useState(false); // Re-seed the draft each time a lane is opened useEffect(() => { @@ -61,6 +67,7 @@ export const LaneDetailsSheet: React.FC = ({ isOpen, onCl setSecondaryObjectiveId(node.SecondaryObjectiveId ?? null); setLinkedNeedId(node.LinkedNeedId ?? null); setLinkedMapId(node.LinkedMapId ?? null); + setIsConfirmingDelete(false); } }, [node, isOpen]); @@ -140,98 +147,161 @@ export const LaneDetailsSheet: React.FC = ({ isOpen, onCl {node ? t('command.edit_lane_title', { lane: node.Name }) : t('command.edit_lane')} - {renderLeadEditor('primary', primaryLead, setPrimaryLead)} - {renderLeadEditor('secondary', secondaryLead, setSecondaryLead)} + {isConfirmingDelete ? ( + + {resourceCount > 0 ? t('command.delete_lane_resources_message', { count: resourceCount }) : t('command.delete_lane_message')} - - {t('command.primary_objective_label')} - - - {openObjectives.map((objective) => ( - - ))} - - - - - {t('command.secondary_objective_label')} - - - {openObjectives - .filter((objective) => objective.TacticalObjectiveId !== primaryObjectiveId) - .map((objective) => ( + {resourceCount > 0 ? ( + <> - ))} - - - - - {t('command.linked_need_label')} - - - {openNeeds.map((need) => ( + + + ) : ( - ))} - - - - - {t('command.linked_map_label')} - - - {(maps ?? []).map((map) => ( - - ))} - - + + ) : ( + <> + {renderLeadEditor('primary', primaryLead, setPrimaryLead)} + {renderLeadEditor('secondary', secondaryLead, setSecondaryLead)} - + + {t('command.primary_objective_label')} + + + {openObjectives.map((objective) => ( + + ))} + + + + + {t('command.secondary_objective_label')} + + + {openObjectives + .filter((objective) => objective.TacticalObjectiveId !== primaryObjectiveId) + .map((objective) => ( + + ))} + + + + + {t('command.linked_need_label')} + + + {openNeeds.map((need) => ( + + ))} + + + + + {t('command.linked_map_label')} + + + {(maps ?? []).map((map) => ( + + ))} + + + + + + {onDelete ? ( + + ) : null} + + )} diff --git a/src/components/command/maps-tabbed-card.tsx b/src/components/command/maps-tabbed-card.tsx new file mode 100644 index 0000000..ba570e9 --- /dev/null +++ b/src/components/command/maps-tabbed-card.tsx @@ -0,0 +1,49 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { IncidentMapCard } from '@/components/command/incident-map-card'; +import { IncidentMapsSection } from '@/components/command/incident-maps-section'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { type IncidentCommand, type IncidentMap, type IncidentMapAnnotation } from '@/models/v4/incidentCommand/incidentCommandModels'; + +type MapsTab = 'incident' | 'tactical'; + +interface MapsTabbedCardProps { + callId: string; + command: IncidentCommand; + annotations: IncidentMapAnnotation[]; + maps: IncidentMap[]; + onCreateMap: (name: string, description: string | null, expiresOn: string | null) => void; + onDeleteMap: (incidentMapId: string) => void; + resolveUserName?: (userId: string) => string; +} + +/** + * Single command-board pane hosting both map surfaces: the incident tactical map (default tab) + * and the named tactical maps list. Tabs switch the body; each child renders embedded (no own card). + */ +export const MapsTabbedCard: React.FC = ({ callId, command, annotations, maps, onCreateMap, onDeleteMap, resolveUserName }) => { + const { t } = useTranslation(); + const [tab, setTab] = useState('incident'); + + return ( + + + + + + + {tab === 'incident' ? ( + + ) : ( + + )} + + ); +}; diff --git a/src/components/command/message-commander-sheet.tsx b/src/components/command/message-commander-sheet.tsx new file mode 100644 index 0000000..b0698b3 --- /dev/null +++ b/src/components/command/message-commander-sheet.tsx @@ -0,0 +1,91 @@ +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +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 { Textarea, TextareaInput } from '@/components/ui/textarea'; +import { VStack } from '@/components/ui/vstack'; +import { logger } from '@/lib/logging'; + +interface MessageCommanderSheetProps { + isOpen: boolean; + onClose: () => void; + /** Resolved display name of the current incident commander. */ + commanderName?: string | null; + /** Whether any Deputy Incident Commanders are assigned — gates the include-deputies toggle. */ + hasDeputies: boolean; + /** Returns whether the send succeeded; the sheet closes on success. */ + onSend: (title: string | null, body: string, includeDeputies: boolean) => Promise; +} + +/** Send a free-form message directly to the incident commander (and optionally deputies). */ +export const MessageCommanderSheet: React.FC = ({ isOpen, onClose, commanderName, hasDeputies, onSend }) => { + const { t } = useTranslation(); + const [title, setTitle] = useState(''); + const [body, setBody] = useState(''); + const [includeDeputies, setIncludeDeputies] = useState(false); + const [isSending, setIsSending] = useState(false); + + const reset = useCallback(() => { + setTitle(''); + setBody(''); + setIncludeDeputies(false); + setIsSending(false); + }, []); + + const handleClose = useCallback(() => { + reset(); + onClose(); + }, [reset, onClose]); + + const handleSend = useCallback(async () => { + const trimmedBody = body.trim(); + if (!trimmedBody || isSending) { + return; + } + setIsSending(true); + try { + const ok = await onSend(title.trim() || null, trimmedBody, includeDeputies); + if (ok) { + handleClose(); + } + } catch (error) { + logger.warn({ message: 'MessageCommanderSheet: onSend rejected', context: { error } }); + } finally { + setIsSending(false); + } + }, [body, title, includeDeputies, isSending, onSend, handleClose]); + + return ( + + + {t('command.message_commander')} + {commanderName ? t('command.message_commander_hint_named', { name: commanderName }) : t('command.message_commander_hint')} + + + + + + + + {hasDeputies ? ( + + {t('command.include_deputies')} + + + ) : null} + + + + + ); +}; diff --git a/src/components/command/resource-cards.tsx b/src/components/command/resource-cards.tsx index e7d2fdb..2e5b807 100644 --- a/src/components/command/resource-cards.tsx +++ b/src/components/command/resource-cards.tsx @@ -1,4 +1,4 @@ -import { CloudOff, MapPin, Trash2, Truck } from 'lucide-react-native'; +import { CloudOff, Eye, MapPin, Truck } from 'lucide-react-native'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet } from 'react-native'; @@ -39,14 +39,15 @@ const LaneBadge: React.FC<{ label: string }> = ({ label }) => ( interface CardShellProps { isLocal: boolean; - onRelease: () => void; + /** Opens the resource details sheet (hosts the release action). */ + onView: () => void; testID: string; - removeTestID: string; + viewTestID: string; children: React.ReactNode; } -/** Shared card chrome: rounded container + offline marker + release button. */ -const CardShell: React.FC = ({ isLocal, onRelease, testID, removeTestID, children }) => { +/** Shared card chrome: rounded container + offline marker + details button. */ +const CardShell: React.FC = ({ isLocal, onView, testID, viewTestID, children }) => { const { t } = useTranslation(); return ( @@ -55,8 +56,8 @@ const CardShell: React.FC = ({ isLocal, onRelease, testID, remov {isLocal ? : null} - - + + @@ -73,18 +74,19 @@ interface UnitResourceCardProps { roles: ActiveUnitRoleResultData[]; laneLabel: string; isLocal: boolean; - onRelease: () => void; + /** Opens the resource details sheet (hosts the release action). */ + onView: () => void; testID: string; - removeTestID: string; + viewTestID: string; } -export const UnitResourceCard: React.FC = ({ name, unit, status, roles, laneLabel, isLocal, onRelease, testID, removeTestID }) => { +export const UnitResourceCard: React.FC = ({ name, unit, status, roles, laneLabel, isLocal, onView, testID, viewTestID }) => { const { t } = useTranslation(); const subtitle = [unit?.Type, unit?.GroupName].filter(Boolean).join(' • '); const filledRoles = roles.filter((role) => !!role.FullName || !!role.UserId); return ( - + @@ -139,18 +141,19 @@ interface PersonnelResourceCardProps { person?: PersonnelInfoResultData; laneLabel: string; isLocal: boolean; - onRelease: () => void; + /** Opens the resource details sheet (hosts the release action). */ + onView: () => void; testID: string; - removeTestID: string; + viewTestID: string; } -export const PersonnelResourceCard: React.FC = ({ name, person, laneLabel, isLocal, onRelease, testID, removeTestID }) => { +export const PersonnelResourceCard: React.FC = ({ name, person, laneLabel, isLocal, onView, testID, viewTestID }) => { const initials = person ? `${person.FirstName?.[0] ?? ''}${person.LastName?.[0] ?? ''}`.toUpperCase() : name.slice(0, 2).toUpperCase(); const subtitle = [person?.GroupName, person?.IdentificationNumber].filter(Boolean).join(' • '); const statusDot = asHexColor(person?.StatusColor); return ( - + {initials} diff --git a/src/components/command/resource-details-sheet.tsx b/src/components/command/resource-details-sheet.tsx new file mode 100644 index 0000000..1403431 --- /dev/null +++ b/src/components/command/resource-details-sheet.tsx @@ -0,0 +1,333 @@ +import { router } from 'expo-router'; +import { Mail, Map as MapIcon, MapPin, Phone, Truck, User } from 'lucide-react-native'; +import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView, StyleSheet } from 'react-native'; + +import { getMapDataAndMarkers } from '@/api/mapping/mapping'; +import { Badge, BadgeText } from '@/components/ui/badge'; +import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Icon } from '@/components/ui/icon'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { View } from '@/components/ui/view'; +import { VStack } from '@/components/ui/vstack'; +import { parseUtcMs } from '@/lib/utils'; +import type { MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; +import type { PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; +import type { ActiveUnitRoleResultData } from '@/models/v4/unitRoles/activeUnitRoleResultData'; +import type { UnitResultData } from '@/models/v4/units/unitResultData'; +import type { UnitStatusResultData } from '@/models/v4/unitStatus/unitStatusResultData'; + +/** Server status colors arrive as hex ('#3498db') or legacy css labels — only trust hex. */ +const asHexColor = (value?: string | null) => (value && /^#[0-9a-fA-F]{3,8}$/.test(value.trim()) ? value.trim() : undefined); + +const formatTimestamp = (iso?: string | null) => { + if (!iso) { + return null; + } + const ms = parseUtcMs(iso); + return ms === null ? null : new Date(ms).toLocaleString(); +}; + +const parseCoords = (lat?: number | string | null, lon?: number | string | null): { latitude: number; longitude: number } | null => { + const latNum = typeof lat === 'string' ? parseFloat(lat) : lat; + const lonNum = typeof lon === 'string' ? parseFloat(lon) : lon; + if (latNum === null || latNum === undefined || lonNum === null || lonNum === undefined || Number.isNaN(latNum) || Number.isNaN(lonNum)) { + return null; + } + // (0,0) is the server's "no fix" placeholder, not a real position; reject out-of-range too + if ((latNum === 0 && lonNum === 0) || Math.abs(latNum) > 90 || Math.abs(lonNum) > 180) { + return null; + } + return { latitude: latNum, longitude: lonNum }; +}; + +const ColorBadge: React.FC<{ label: string; color?: string | null; testID?: string }> = ({ label, color, testID }) => { + const hex = asHexColor(color); + return ( + + {label} + + ); +}; + +const LabeledRow: React.FC<{ label: string; children: React.ReactNode; testID?: string }> = ({ label, children, testID }) => ( + + {label} + {children} + +); + +interface ResourceDetailsSheetProps { + isOpen: boolean; + onClose: () => void; + kind: 'unit' | 'person'; + resourceId: string; + /** Display name fallback when the roster entry is missing. */ + name: string; + unit?: UnitResultData; + /** Live unit status (state, destination, last known coordinates). */ + status?: UnitStatusResultData; + /** Unit role seats with current assignees. */ + unitRoles?: ActiveUnitRoleResultData[]; + person?: PersonnelInfoResultData; + /** Lane the resource sits in ('' pool → host passes the unassigned label, no color). */ + laneName?: string | null; + laneColor?: string | null; + /** Destructive action label (remove-from-lane vs release-from-incident). */ + actionLabel: string; + actionTestID: string; + onAction: () => void; + /** Optional second action (lane items also get a full Release). */ + secondaryActionLabel?: string; + secondaryActionTestID?: string; + onSecondaryAction?: () => void; +} + +/** + * Read-only inspector for an incident resource (unit or person): roster info, live status/staffing, + * role assignments, and last known position. Hosts the destructive lane/pool action for the IC. + */ +export const ResourceDetailsSheet: React.FC = ({ + isOpen, + onClose, + kind, + resourceId, + name, + unit, + status, + unitRoles = [], + person, + laneName, + laneColor, + actionLabel, + actionTestID, + onAction, + secondaryActionLabel, + secondaryActionTestID, + onSecondaryAction, +}) => { + const { t } = useTranslation(); + const [marker, setMarker] = useState(null); + const mapNavTimerRef = useRef | null>(null); + + // Cancel a pending map navigation if the sheet unmounts before the delay elapses + useEffect(() => { + return () => { + if (mapNavTimerRef.current !== null) { + clearTimeout(mapNavTimerRef.current); + mapNavTimerRef.current = null; + } + }; + }, []); + + // Personnel have no coordinates in their info payload — pull the shared map markers and + // find this resource's pin (`u{unitId}` / `p{userId}` convention). + useEffect(() => { + if (!isOpen) { + return; + } + // Clear the previous resource's marker so its coordinates never leak into this view + setMarker(null); + let cancelled = false; + getMapDataAndMarkers() + .then((result) => { + if (cancelled) { + return; + } + const markerId = `${kind === 'unit' ? 'u' : 'p'}${resourceId}`; + setMarker((result?.Data?.MapMakerInfos ?? []).find((info) => info.Id === markerId) ?? null); + }) + .catch(() => { + if (!cancelled) { + setMarker(null); + } + }); + return () => { + cancelled = true; + }; + }, [isOpen, kind, resourceId]); + + const coords = (kind === 'unit' ? parseCoords(status?.Latitude, status?.Longitude) : null) ?? parseCoords(marker?.Latitude, marker?.Longitude); + const positionText = coords ? `${coords.latitude.toFixed(5)}, ${coords.longitude.toFixed(5)}` : null; + const positionTimestamp = kind === 'unit' ? formatTimestamp(status?.TimestampUtc ?? status?.Timestamp) : null; + const laneHex = asHexColor(laneColor); + + const openResourceMap = () => { + if (!coords) { + return; + } + const displayName = kind === 'unit' ? (unit?.Name ?? name) : person ? `${person.FirstName} ${person.LastName}` : name; + // The sheet is an RN Modal — it renders above pushed routes, so close it first and + // navigate once the dismiss animation has had a beat to run. + onClose(); + mapNavTimerRef.current = setTimeout(() => { + mapNavTimerRef.current = null; + router.push({ + pathname: '/resource-map', + params: { + latitude: String(coords.latitude), + longitude: String(coords.longitude), + title: displayName, + ...(laneHex ? { color: laneHex } : {}), + }, + } as never); + }, 300); + }; + + return ( + + + + + + {kind === 'unit' ? t('command.unit_details_title') : t('command.person_details_title')} + + + + + {kind === 'unit' ? (unit?.Name ?? name) : person ? `${person.FirstName} ${person.LastName}` : name} + + + {kind === 'unit' ? [unit?.Type, unit?.GroupName].filter(Boolean).join(' • ') : [person?.GroupName, person?.IdentificationNumber].filter(Boolean).join(' • ')} + + {laneName ? ( + + {laneHex ? : null} + + {laneName} + + + ) : null} + + + {kind === 'person' && (person?.EmailAddress || person?.MobilePhone) ? ( + + {person?.EmailAddress ? ( + + + {person.EmailAddress} + + ) : null} + {person?.MobilePhone ? ( + + + {person.MobilePhone} + + ) : null} + + ) : null} + + + + {kind === 'unit' ? ( + status?.State ? ( + + ) : ( + {t('command.resource_status_unknown')} + ) + ) : ( + <> + {person?.Status ? : null} + {person?.Staffing ? : null} + {!person?.Status && !person?.Staffing ? {t('command.resource_status_unknown')} : null} + + )} + + {kind === 'unit' && status?.Note ? {status.Note} : null} + {kind === 'unit' && (status?.DestinationName || status?.Eta) ? ( + {[status?.DestinationName, status?.Eta ? t('command.unit_eta', { eta: status.Eta }) : ''].filter(Boolean).join(' • ')} + ) : null} + + + + {positionText ? ( + + + + + {positionText} + + {positionTimestamp ? {positionTimestamp} : null} + + + + + + ) : ( + {t('command.resource_position_unknown')} + )} + + + {kind === 'unit' && unitRoles.length > 0 ? ( + + + {unitRoles.map((role) => ( + + {role.Name} + {role.FullName || t('command.role_open')} + + ))} + + + ) : null} + + {kind === 'person' && (person?.Roles ?? []).length > 0 ? ( + + + {(person?.Roles ?? []).map((role) => ( + + {role} + + ))} + + + ) : null} + + + + {secondaryActionLabel && onSecondaryAction ? ( + + ) : null} + + + + ); +}; + +const styles = StyleSheet.create({ + badgeFallback: { + backgroundColor: '#6b7280', + }, + laneDot: { + width: 10, + height: 10, + borderRadius: 5, + }, +}); diff --git a/src/components/command/structure-section.tsx b/src/components/command/structure-section.tsx index c89a643..c5b7df9 100644 --- a/src/components/command/structure-section.tsx +++ b/src/components/command/structure-section.tsx @@ -1,4 +1,4 @@ -import { CloudOff, Pencil, Plus, Trash2, UserPlus } from 'lucide-react-native'; +import { CloudOff, Eye, Pencil, Plus, UserPlus } from 'lucide-react-native'; import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -23,27 +23,16 @@ interface StructureSectionProps { /** Display name for a lane lead slot (resolves user ids to names); external leads pass through. */ resolveLeadName?: (userId?: string | null, externalName?: string | null) => string | null; onAddLane: () => void; - onDeleteLane: (nodeId: string) => void; - /** Open the lane details editor (leads, linked objectives/need). */ + /** Open the lane details editor (leads, linked objectives/need, delete). */ onEditLane?: (nodeId: string) => void; onAssignResource: (nodeId: string) => void; onMoveResource: (assignmentId: string, targetNodeId: string) => void | Promise; - onReleaseResource: (assignmentId: string) => void; + /** Opens the resource details sheet for a lane assignment (hosts remove-from-lane). */ + onViewResource: (assignment: ResourceAssignment) => void; } /** ICS command structure — lanes (Division/Group/Branch/...) with their assigned resources. */ -export const StructureSection: React.FC = ({ - nodes, - assignments, - resolveResourceName, - resolveLeadName, - onAddLane, - onDeleteLane, - onEditLane, - onAssignResource, - onMoveResource, - onReleaseResource, -}) => { +export const StructureSection: React.FC = ({ nodes, assignments, resolveResourceName, resolveLeadName, onAddLane, onEditLane, onAssignResource, onMoveResource, onViewResource }) => { const { t } = useTranslation(); const [selectedAssignmentId, setSelectedAssignmentId] = useState(null); @@ -138,9 +127,6 @@ export const StructureSection: React.FC = ({ onAssignResource(node.CommandStructureNodeId)} className="p-2" testID={`lane-assign-${node.CommandStructureNodeId}`}> - onDeleteLane(node.CommandStructureNodeId)} className="p-2" testID={`lane-delete-${node.CommandStructureNodeId}`}> - - @@ -175,8 +161,8 @@ export const StructureSection: React.FC = ({ ) : null} - onReleaseResource(assignment.ResourceAssignmentId)} className="p-1" testID={`lane-resource-release-${assignment.ResourceAssignmentId}`}> - + onViewResource(assignment)} className="p-1" testID={`lane-resource-view-${assignment.ResourceAssignmentId}`}> + diff --git a/src/components/command/timeline-section.tsx b/src/components/command/timeline-section.tsx index 97db826..7c26e5f 100644 --- a/src/components/command/timeline-section.tsx +++ b/src/components/command/timeline-section.tsx @@ -1,3 +1,4 @@ +import { router } from 'expo-router'; import { RefreshCw } from 'lucide-react-native'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -14,6 +15,8 @@ import type { CommandLogEntry } from '@/models/v4/incidentCommand/incidentComman const VISIBLE_BATCH = 15; interface TimelineSectionProps { + /** Enables the "View Log" fullscreen link when set (live board screens). */ + callId?: string; entries: CommandLogEntry[]; onRefresh: () => void; } @@ -24,11 +27,10 @@ const formatTime = (iso: string) => { }; /** Auto-logged, time-stamped incident log — every board action the server records. */ -export const TimelineSection: React.FC = ({ entries, onRefresh }) => { +export const TimelineSection: React.FC = ({ callId, entries, onRefresh }) => { const { t } = useTranslation(); - const [visibleCount, setVisibleCount] = useState(VISIBLE_BATCH); - const visible = entries.slice(0, visibleCount); + const visible = entries.slice(0, VISIBLE_BATCH); return ( @@ -52,9 +54,9 @@ export const TimelineSection: React.FC = ({ entries, onRef {entry.Description} ))} - {entries.length > visibleCount ? ( - ) : null} diff --git a/src/components/notifications/NotificationDetail.tsx b/src/components/notifications/NotificationDetail.tsx index 56e5004..5cf30de 100644 --- a/src/components/notifications/NotificationDetail.tsx +++ b/src/components/notifications/NotificationDetail.tsx @@ -2,20 +2,10 @@ import { useNotifications } from '@novu/react-native'; import { ArrowLeft, Calendar, ExternalLink, Trash2 } from 'lucide-react-native'; import { colorScheme } from 'nativewind'; import React, { useEffect } from 'react'; -import { Animated, Dimensions, Platform, Pressable, SafeAreaView, StatusBar, StyleSheet, Text, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { Animated, Dimensions, Platform, Pressable, SafeAreaView, StatusBar, type StyleProp, StyleSheet, Text, View, type ViewStyle } from 'react-native'; -// Define the interface directly in this file -interface NotificationPayload { - id: string; - title?: string; - body: string; - createdAt: string; - read?: boolean; - type?: string; - referenceId?: string; - referenceType?: string; - metadata?: Record; -} +import { type NotificationPayload } from '@/types/notification'; interface NotificationDetailProps { notification: NotificationPayload; @@ -29,6 +19,7 @@ const SIDEBAR_WIDTH = Math.min(width * 0.85, 400); const STATUS_BAR_HEIGHT = Platform.OS === 'ios' ? 44 : StatusBar.currentHeight || 0; export const NotificationDetail = ({ notification, onClose, onDelete, onNavigateToReference }: NotificationDetailProps) => { + const { t } = useTranslation(); const { refetch } = useNotifications(); const slideAnim = React.useRef(new Animated.Value(SIDEBAR_WIDTH)).current; const fadeAnim = React.useRef(new Animated.Value(0)).current; @@ -114,7 +105,7 @@ export const NotificationDetail = ({ notification, onClose, onDelete, onNavigate - Notification + {t('notifications.notification')} @@ -143,7 +134,7 @@ export const NotificationDetail = ({ notification, onClose, onDelete, onNavigate {notification.metadata && Object.keys(notification.metadata).length > 0 ? ( - Additional Information + {t('notifications.additional_info')} {Object.entries(notification.metadata).map(([key, value]) => ( {formatKey(key)}: @@ -156,7 +147,7 @@ export const NotificationDetail = ({ notification, onClose, onDelete, onNavigate {notification.referenceType && notification.referenceId ? ( - View {notification.referenceType} + {t('notifications.view_reference', { type: notification.referenceType })} ) : null} @@ -175,14 +166,14 @@ const formatKey = (key: string): string => { }; // Helper function to format metadata values for display -const formatValue = (value: any): string => { +const formatValue = (value: unknown): string => { if (value === null || value === undefined) return 'N/A'; if (typeof value === 'object') return JSON.stringify(value); return String(value); }; // Helper function to get tag style based on notification type -const getTypeTagStyle = (type: string): any => { +const getTypeTagStyle = (type: string): StyleProp => { const lowerType = type.toLowerCase(); if (lowerType.includes('alert') || lowerType.includes('emergency')) { diff --git a/src/components/notifications/NotificationInbox.tsx b/src/components/notifications/NotificationInbox.tsx index 3a10edd..dd4b2a6 100644 --- a/src/components/notifications/NotificationInbox.tsx +++ b/src/components/notifications/NotificationInbox.tsx @@ -1,7 +1,9 @@ import { useNotifications } from '@novu/react-native'; +import { router } from 'expo-router'; import { CheckCircle, ChevronRight, Circle, ExternalLink, MoreVertical, Trash2, X } from 'lucide-react-native'; import { colorScheme } from 'nativewind'; import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Animated, Dimensions, Platform, Pressable, RefreshControl, SafeAreaView, StatusBar, StyleSheet, View } from 'react-native'; import { deleteMessage } from '@/api/novu/inbox'; @@ -20,13 +22,50 @@ const { width } = Dimensions.get('window'); const SIDEBAR_WIDTH = Math.min(width * 0.85, 400); const STATUS_BAR_HEIGHT = Platform.OS === 'ios' ? 44 : StatusBar.currentHeight || 0; +/** The notification item shape returned by Novu's useNotifications hook. */ +type NovuNotification = NonNullable['notifications']>[number]; + +/** + * Maps a Novu inbox notification to our display payload. Reference info comes from the trigger + * payload (`data`): either explicit referenceType/referenceId, or the eventCode prefix scheme + * the server uses (C{callId} = call, N/M{messageId} = message/notification). + */ +const toNotificationPayload = (item: NovuNotification): NotificationPayload => { + const data = (item.data ?? {}) as Record; + const eventCode = typeof data.eventCode === 'string' ? data.eventCode : undefined; + const eventId = typeof data.eventId === 'string' ? data.eventId : undefined; + + let referenceType = typeof data.referenceType === 'string' ? data.referenceType : undefined; + let referenceId = typeof data.referenceId === 'string' ? data.referenceId : undefined; + + if ((!referenceType || !referenceId) && eventCode && eventCode.length > 1) { + const prefix = eventCode.charAt(0).toUpperCase(); + if (prefix === 'C') { + referenceType = 'call'; + referenceId = referenceId ?? eventId ?? eventCode.slice(1); + } + } + + return { + id: item.id, + title: item.subject, + body: item.body, + createdAt: item.createdAt, + read: item.isRead, + type: typeof data.type === 'string' ? data.type : undefined, + referenceId, + referenceType: referenceType as NotificationPayload['referenceType'], + metadata: data, + }; +}; + interface NotificationInboxProps { isOpen: boolean; onClose: () => void; } interface NotificationRowProps { - item: any; + item: NovuNotification; isSelectionMode: boolean; isSelected: boolean; onPress: (notification: NotificationPayload) => void; @@ -45,20 +84,7 @@ const formatNotificationDate = (createdAt: string | undefined | null): string => const formatNotificationTime = (createdAt: string | undefined | null): string => parseNotificationDate(createdAt)?.toLocaleTimeString() ?? ''; const NotificationRow = React.memo(({ item, isSelectionMode, isSelected, onPress, onLongPress, onNavigateToReference }) => { - const notification: NotificationPayload = React.useMemo( - () => ({ - id: item.id, - title: item.subject, - body: item.body, - createdAt: item.createdAt, - read: item.read, - type: item.type, - referenceId: item.payload?.referenceId, - referenceType: item.payload?.referenceType, - metadata: item.payload?.metadata, - }), - [item] - ); + const notification: NotificationPayload = React.useMemo(() => toNotificationPayload(item), [item]); const formattedDate = React.useMemo(() => formatNotificationDate(notification.createdAt), [notification.createdAt]); const formattedTime = React.useMemo(() => formatNotificationTime(notification.createdAt), [notification.createdAt]); @@ -72,8 +98,8 @@ const NotificationRow = React.memo(({ item, isSelectionMod }, [onNavigateToReference, notification.referenceType, notification.referenceId]); return ( - - {!item.read ? : null} + + {!notification.read ? : null} {isSelectionMode ? ( @@ -82,7 +108,7 @@ const NotificationRow = React.memo(({ item, isSelectionMod ) : null} - {notification.title} + {notification.title} {formattedDate} {formattedTime} @@ -106,8 +132,9 @@ const NotificationRow = React.memo(({ item, isSelectionMod NotificationRow.displayName = 'NotificationRow'; export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) => { + const { t } = useTranslation(); const userId = useAuthStore((state) => state.userId); - const config = useCoreStore((state: any) => state.config); + const config = useCoreStore((state) => state.config); const { notifications, isLoading, fetchMore, hasMore, refetch } = useNotifications(); const showToast = useToastStore((state) => state.showToast); const [selectedNotification, setSelectedNotification] = useState(null); @@ -201,63 +228,70 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = setSelectedNotificationIds(new Set()); }, []); - const selectAllNotifications = () => { - const allIds = notifications?.map((item: any) => item.id) || []; + const selectAllNotifications = React.useCallback(() => { + const allIds = notifications?.map((item) => item.id) ?? []; setSelectedNotificationIds(new Set(allIds)); - }; + }, [notifications]); - const deselectAllNotifications = () => { + const deselectAllNotifications = React.useCallback(() => { setSelectedNotificationIds(new Set()); - }; + }, []); - const handleBulkDelete = () => { + const handleBulkDelete = React.useCallback(() => { if (selectedNotificationIds.size > 0) { setShowDeleteConfirmModal(true); } - }; + }, [selectedNotificationIds.size]); const confirmBulkDelete = React.useCallback(async () => { setIsDeletingSelected(true); setShowDeleteConfirmModal(false); try { - const deletePromises = Array.from(selectedNotificationIds).map((id) => deleteMessage(id)); - await Promise.all(deletePromises); - - showToast('success', `${selectedNotificationIds.size} notification${selectedNotificationIds.size > 1 ? 's' : ''} removed`); - exitSelectionMode(); + const results = await Promise.allSettled(Array.from(selectedNotificationIds).map((id) => deleteMessage(id))); + const succeeded = results.filter((result) => result.status === 'fulfilled').length; + const failed = results.length - succeeded; + + if (failed === 0) { + showToast('success', t('notifications.delete_success', { count: succeeded })); + exitSelectionMode(); + } else if (succeeded > 0) { + showToast('warning', t('notifications.delete_partial', { succeeded, failed })); + exitSelectionMode(); + } else { + showToast('error', t('notifications.delete_error')); + } refetch(); - } catch (error) { - showToast('error', 'Failed to remove notifications'); } finally { setIsDeletingSelected(false); } - }, [selectedNotificationIds, showToast, exitSelectionMode, refetch]); + }, [selectedNotificationIds, showToast, exitSelectionMode, refetch, t]); const handleDeleteNotification = React.useCallback( - async (_id: string) => { + async (id: string) => { try { - await deleteMessage(_id); - showToast('success', 'Notification removed'); + await deleteMessage(id); + showToast('success', t('notifications.delete_one_success')); refetch(); - } catch (error) { - showToast('error', 'Failed to remove notification'); + } catch { + showToast('error', t('notifications.delete_one_error')); } }, - [showToast, refetch] + [showToast, refetch, t] ); const handleNavigateToReference = React.useCallback( (referenceType: string, referenceId: string) => { - // TODO: Implement navigation based on reference type - console.log('Navigate to:', referenceType, referenceId); onClose(); + if (referenceType === 'call') { + router.push(`/call/${referenceId}`); + } }, [onClose] ); const renderItem = React.useCallback( - ({ item }: { item: any }) => ( + ({ item }: { item: NovuNotification }) => ( ( - No updates available + {t('notifications.empty')} ), - [] + [t] ); if (!isOpen) { @@ -316,23 +350,23 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = {isSelectionMode ? ( <> - {selectedNotificationIds.size} selected + {t('notifications.selected_count', { count: selectedNotificationIds.size })} ) : ( <> - Notifications + {t('notifications.title')} @@ -351,7 +385,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = ) : !userId || !config ? ( - Unable to load notifications + {t('notifications.load_error')} ) : ( {/* Delete Confirmation Modal */} - setShowDeleteConfirmModal(false)} {...({} as any)}> + setShowDeleteConfirmModal(false)}> - Confirm Delete + {t('notifications.confirm_delete_title')} - - Are you sure you want to delete {selectedNotificationIds.size} notification{selectedNotificationIds.size > 1 ? 's' : ''}? This action cannot be undone. - + {t('notifications.confirm_delete_message', { count: selectedNotificationIds.size })} diff --git a/src/models/v4/device/pushRegistrationInput.ts b/src/models/v4/device/pushRegistrationInput.ts index 67d5eab..b1aa2a3 100644 --- a/src/models/v4/device/pushRegistrationInput.ts +++ b/src/models/v4/device/pushRegistrationInput.ts @@ -4,4 +4,6 @@ export class PushRegistrationInput { public Platform: number = 0; public DeviceUuid: string = ''; public Prefix: string = ''; + /** Source app marker ("IC") — routes the Novu credential update to the IC-specific subscriber. */ + public Source: string = ''; } diff --git a/src/models/v4/incidentCommand/incidentCommandModels.ts b/src/models/v4/incidentCommand/incidentCommandModels.ts index 3894500..42fafc0 100644 --- a/src/models/v4/incidentCommand/incidentCommandModels.ts +++ b/src/models/v4/incidentCommand/incidentCommandModels.ts @@ -533,6 +533,8 @@ export type IncidentAttachmentsResult = V4Result; export type IncidentCommandSummariesResult = V4Result; export type IncidentMapResult = V4Result; export type IncidentMapsResult = V4Result; +/** Data = number of users the message was delivered to. */ +export type SendMessageToCommandResult = V4Result; // ---- Request inputs ---- @@ -570,6 +572,15 @@ export interface ReopenCommandInput { Reason?: string | null; } +/** Input to send a free-form message directly to the incident commander (and optionally deputies). */ +export interface SendMessageToCommandInput { + CallId: number; + Title?: string | null; + Body: string; + /** Also deliver to assigned Deputy Incident Commanders, not just the current commander. */ + IncludeDeputies?: boolean; +} + /** * Input to update core incident metadata and the ICP/HQ, Staging, and Rehab locations. * Null/omitted fields are left unchanged; empty strings clear. A location whose text is set while diff --git a/src/services/__tests__/push-notification.test.ts b/src/services/__tests__/push-notification.test.ts index c6bdaa3..86720b3 100644 --- a/src/services/__tests__/push-notification.test.ts +++ b/src/services/__tests__/push-notification.test.ts @@ -307,6 +307,7 @@ describe('PushNotificationService (expo-notifications transport)', () => { Platform: 1, DeviceUuid: 'test-device-uuid', Prefix: 'DEPT1', + Source: 'IC', }); }); diff --git a/src/services/push-notification.ts b/src/services/push-notification.ts index e7b4224..5e208c6 100644 --- a/src/services/push-notification.ts +++ b/src/services/push-notification.ts @@ -359,13 +359,15 @@ class PushNotificationService { }, }); - // Register device with backend (user-scoped — the IC app has no unit context) + // Register device with backend (user-scoped — the IC app has no unit context). + // Source "IC" routes the Novu credential update to the IC-specific subscriber, keeping the inbox separate from the Responder app. await registerDevice({ UserId: userId, Token: this.pushToken || '', Platform: Platform.OS === 'ios' ? 1 : 2, DeviceUuid: getDeviceUuid() || '', Prefix: departmentCode, + Source: 'IC', }); return this.pushToken; diff --git a/src/stores/calls/store.ts b/src/stores/calls/store.ts index 496e1d6..fe27bc3 100644 --- a/src/stores/calls/store.ts +++ b/src/stores/calls/store.ts @@ -141,8 +141,15 @@ export const useCallsStore = create()( uncachedIds.map(async (callId) => { try { const result = await getCallExtraData(callId); - const dispatches = result?.Data?.Dispatches ?? []; - return { callId, dispatches: dispatches as DispatchedEventResultData[] }; + const dispatches = (result?.Data?.Dispatches ?? []) as DispatchedEventResultData[]; + const seen = new Set(); + const unique = dispatches.filter((d) => { + const key = `${d.Id}-${d.Name}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + return { callId, dispatches: unique }; } catch { return { callId, dispatches: [] as DispatchedEventResultData[] }; } diff --git a/src/stores/command/store.ts b/src/stores/command/store.ts index 18e1ee3..cefe5ee 100644 --- a/src/stores/command/store.ts +++ b/src/stores/command/store.ts @@ -28,6 +28,7 @@ import { saveMapAnnotation, saveNeed, saveObjective, + sendMessageToCommand, setNeedStatus, startIncidentTimer, transferCommand, @@ -204,6 +205,8 @@ interface CommandState { acknowledgeTimer: (callId: string, incidentTimerId: string) => Promise; /** Transfer command of this incident to another user. Online-only. */ transferIncidentCommand: (callId: string, toUserId: string) => Promise; + /** Send a free-form message directly to the incident's commander (and optionally deputies). Online-only. */ + sendMessageToCommander: (callId: string, title: string | null, body: string, includeDeputies: boolean) => Promise; /** Pull the server-side incident log for this board. */ fetchTimeline: (callId: string) => Promise; @@ -1645,6 +1648,23 @@ export const useCommandStore = create()( } }, + sendMessageToCommander: async (callId: string, title: string | null, body: string, includeDeputies: boolean) => { + const numericCallId = /^\d+$/.test(callId.trim()) ? Number(callId.trim()) : NaN; + if (!Number.isSafeInteger(numericCallId) || numericCallId <= 0 || numericCallId > Number.MAX_SAFE_INTEGER || !body.trim()) { + return false; + } + try { + const result = await sendMessageToCommand({ CallId: numericCallId, Title: title, Body: body.trim(), IncludeDeputies: includeDeputies }); + return (result.Data ?? 0) > 0; + } catch (error) { + logger.warn({ + message: 'SendMessageToCommand failed', + context: { error, callId }, + }); + return false; + } + }, + fetchTimeline: async (callId: string) => { if (isOffline()) { return; diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index e5fe431..52c865a 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -6,6 +6,7 @@ import { logger } from '@/lib/logging'; import { signalRService } from '@/services/signalr.service'; import { useCoreStore } from '../app/core-store'; +import { useCommandStore } from '../command/store'; import { securityStore, useSecurityStore } from '../security/store'; import { useWeatherAlertsStore } from '../weather-alerts/store'; @@ -25,6 +26,94 @@ function extractAlertId(message: unknown): string | undefined { return undefined; } +/** Minimal shape of the incidentCommandUpdated payload — server identifies the + * affected incident by call (PascalCase or lower-camel). */ +interface IncidentCommandSignalRMessage { + CallId?: string; + callId?: string; +} + +function extractCommandCallId(message: unknown): string | undefined { + if (message !== null && typeof message === 'object') { + const m = message as IncidentCommandSignalRMessage; + const id = m.CallId ?? m.callId; + return id !== undefined && id !== null ? String(id) : undefined; + } + return undefined; +} + +/** Per-callId board refresh coalescing: one refresh in flight at a time; an event arriving + * mid-refresh marks the entry dirty and triggers exactly one follow-up refresh. */ +const boardRefreshState = new Map(); + +function coalescedRefreshBoard(callId: string): void { + const state = boardRefreshState.get(callId) ?? { inFlight: false, dirty: false }; + if (state.inFlight) { + state.dirty = true; + boardRefreshState.set(callId, state); + return; + } + state.inFlight = true; + boardRefreshState.set(callId, state); + useCommandStore + .getState() + .refreshBoard(callId) + .catch((error) => { + logger.warn({ message: 'incidentCommandUpdated: failed to refresh board', context: { callId, error } }); + }) + .finally(() => { + const current = boardRefreshState.get(callId); + if (current?.dirty) { + boardRefreshState.set(callId, { inFlight: false, dirty: false }); + coalescedRefreshBoard(callId); + } else { + boardRefreshState.delete(callId); + } + }); +} + +/** Trailing debounce so a burst of department-wide incidentCommandUpdated events + * (unknown/untracked incidents) collapses into a single full sync. Syncs are + * serialized: an event arriving mid-sync marks dirty and runs one follow-up. */ +let incidentCommandResyncTimer: ReturnType | null = null; +let fullSyncInFlight = false; +let fullSyncDirty = false; + +function runFullSync(): void { + fullSyncInFlight = true; + useCommandStore + .getState() + .syncFromServer() + .catch((error) => { + logger.warn({ message: 'incidentCommandUpdated: failed to sync from server', context: { error } }); + }) + .finally(() => { + fullSyncInFlight = false; + if (fullSyncDirty) { + fullSyncDirty = false; + runFullSync(); + } + }); +} + +function debouncedFullSync(): void { + if (fullSyncInFlight) { + fullSyncDirty = true; + return; + } + if (incidentCommandResyncTimer) { + clearTimeout(incidentCommandResyncTimer); + } + incidentCommandResyncTimer = setTimeout(() => { + incidentCommandResyncTimer = null; + if (fullSyncInFlight) { + fullSyncDirty = true; + return; + } + runFullSync(); + }, 2000); +} + interface SignalRState { isUpdateHubConnected: boolean; lastUpdateMessage: unknown; @@ -80,6 +169,7 @@ export const useSignalRStore = create((set, get) => ({ 'weatherAlertReceived', 'weatherAlertUpdated', 'weatherAlertExpired', + 'incidentCommandUpdated', 'onConnected', ]; updateEvents.forEach((event) => signalRService.removeAllListeners(event)); @@ -99,6 +189,7 @@ export const useSignalRStore = create((set, get) => ({ 'weatherAlertReceived', 'weatherAlertUpdated', 'weatherAlertExpired', + 'incidentCommandUpdated', 'onConnected', ], }); @@ -160,6 +251,18 @@ export const useSignalRStore = create((set, get) => ({ } }); + signalRService.on('incidentCommandUpdated', (message) => { + set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() }); + const callId = extractCommandCallId(message); + const commandState = useCommandStore.getState(); + if (callId && commandState.boards[callId]) { + coalescedRefreshBoard(callId); + } else { + // Unknown or untracked incident — resync the full bundle (debounced). + debouncedFullSync(); + } + }); + signalRService.on('onConnected', () => { logger.info({ message: 'Connected to update SignalR hub', diff --git a/src/stores/units/store.ts b/src/stores/units/store.ts index fd117f4..85653e3 100644 --- a/src/stores/units/store.ts +++ b/src/stores/units/store.ts @@ -3,6 +3,8 @@ import { create } from 'zustand'; import { getAllUnitStatuses } from '@/api/satuses/statuses'; import { getUnits } from '@/api/units/units'; import { getAllUnitStatuses as getUnitCurrentStatuses } from '@/api/units/unitStatuses'; +import { cacheManager } from '@/lib/cache/cache-manager'; +import { logger } from '@/lib/logging'; import { type UnitTypeStatusResultData } from '@/models/v4/statuses/unitTypeStatusResultData'; import { type UnitResultData } from '@/models/v4/units/unitResultData'; import { type UnitStatusResultData } from '@/models/v4/unitStatus/unitStatusResultData'; @@ -26,9 +28,32 @@ export const useUnitsStore = create((set) => ({ fetchUnits: async () => { set({ isLoading: true, error: null }); try { - const [unitsResponse, unitStatusesResponse, currentStatusesResponse] = await Promise.all([getUnits(), getAllUnitStatuses(), getUnitCurrentStatuses()]); - set({ units: unitsResponse.Data ?? [], unitStatuses: unitStatusesResponse.Data ?? [], unitCurrentStatuses: currentStatusesResponse.Data ?? [], isLoading: false }); + // Settled so a failing status endpoint can't take the unit roster down with it + const [unitsResult, unitStatusesResult, currentStatusesResult] = await Promise.allSettled([getUnits(), getAllUnitStatuses(), getUnitCurrentStatuses()]); + + const units = unitsResult.status === 'fulfilled' ? (unitsResult.value.Data ?? []) : []; + const unitStatuses = unitStatusesResult.status === 'fulfilled' ? (unitStatusesResult.value.Data ?? []) : []; + const unitCurrentStatuses = currentStatusesResult.status === 'fulfilled' ? (currentStatusesResult.value.Data ?? []) : []; + + if (unitsResult.status === 'rejected') { + logger.error({ message: 'Failed to fetch units roster', context: { error: unitsResult.reason } }); + } + if (unitStatusesResult.status === 'rejected') { + logger.warn({ message: 'Failed to fetch unit type statuses', context: { error: unitStatusesResult.reason } }); + } + if (currentStatusesResult.status === 'rejected') { + logger.warn({ message: 'Failed to fetch current unit statuses', context: { error: currentStatusesResult.reason } }); + } + + // A cached empty roster (2-day TTL) would stick forever — drop it so the + // next fetch hits the server again. Only evict when the fetch itself succeeded. + if (unitsResult.status === 'fulfilled' && units.length === 0) { + cacheManager.remove('/Units/GetAllUnits'); + } + + set({ units, unitStatuses, unitCurrentStatuses, isLoading: false, error: unitsResult.status === 'rejected' ? 'Failed to fetch units' : null }); } catch (error) { + logger.error({ message: 'Failed to fetch units', context: { error } }); set({ error: 'Failed to fetch units', isLoading: false }); } }, diff --git a/src/translations/ar.json b/src/translations/ar.json index 14c70ca..bc1f5cb 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -372,8 +372,10 @@ "added_to_command": "تمت الإضافة إلى لوحة القيادة", "agency_label": "الشركة / الجهة (اختياري)", "agency_placeholder": "مثال: أمن أكمي، الهلال الأحمر", + "all": "الكل", "assign": "تعيين", "assign_resource": "تعيين مورد", + "assigned": "معيّن", "assignment_label": "المهمة / الموقع", "assignment_placeholder": "مثال: هجوم داخلي، القطاع أ", "channel_connected": "متصل", @@ -386,6 +388,11 @@ "command_details": "تفاصيل القيادة", "crew_placeholder": "اسم الشخص أو الفريق", "current_commander": "قائد الحادث", + "delete_lane_confirm": "حذف المسار", + "delete_lane_message": "حذف هذا المسار؟ لا يمكن التراجع عن هذا.", + "delete_lane_move_resources": "نقل إلى الموارد", + "delete_lane_release_resources": "إخلاء من الحادث", + "delete_lane_resources_message": "يحتوي هذا المسار على {{count}} موارد معيّنة. انقلها إلى قائمة الموارد أو أخلها من الحادث.", "drag_move_hint": "اضغط مطولاً على المورد لسحبه، أو اضغط عليه ثم اضغط على مسار.", "edit_lane": "تعديل المسار", "edit_lane_title": "تعديل {{lane}}", @@ -466,6 +473,7 @@ "incident_weather_none": "لا توجد تنبيهات طقس نشطة لهذا الموقع", "incident_weather_section": "تنبيهات الطقس", "incident_weather_view": "عرض", + "include_deputies": "إرسال أيضًا إلى نواب القائد", "info_save_error": "فشل حفظ معلومات الحادث", "info_save_success": "تم حفظ معلومات الحادث", "lane_color_label": "لون المسار (اختياري — يلوّن علاماته على الخريطة)", @@ -504,7 +512,18 @@ "linked_need_label": "الاحتياج المرتبط", "location_geocode_hint": "لم يتم تحديد دبوس — سيتم تحويل النص إلى إحداثيات عند الحفظ", "location_placeholder": "صف الموقع (عنوان، معلم، ...)", + "log_entries_count": "{{count}} إدخالات", + "log_no_results": "لا توجد إدخالات مطابقة", + "log_results_count": "{{shown}} من {{total}} إدخالات", + "log_search_placeholder": "ابحث في إدخالات السجل", "map_command_section": "لوحة القيادة", + "message_body_placeholder": "الرسالة", + "message_commander": "رسالة إلى القائد", + "message_commander_hint": "تُرسل مباشرة إلى قائد الحادث عبر قنوات الإشعارات الخاصة به.", + "message_commander_hint_named": "تُرسل مباشرة إلى {{name}} عبر قنوات الإشعارات الخاصة به.", + "message_send_error": "فشل إرسال الرسالة", + "message_send_success": "تم إرسال الرسالة إلى القيادة", + "message_subject_placeholder": "الموضوع (اختياري)", "move": "نقل", "move_conflict_message": "{{resource}} معيّن بالفعل إلى {{lane}}. هل تريد نقله إلى {{target}}؟", "move_conflict_title": "المورد معيّن بالفعل", @@ -609,6 +628,7 @@ "par_ok": "PAR موافق", "par_pending": "PAR قيد الانتظار", "par_warning": "تحذير", + "person_details_title": "تفاصيل الشخص", "person_label": "الاسم", "person_name_placeholder": "اسم الشخص", "person_placeholder": "الشخص المعين لهذا الدور", @@ -621,6 +641,8 @@ "provisional_badge": "غير متصل — بانتظار المزامنة", "rehab_location_label": "منطقة الاستراحة", "release_from_command": "تحرير من القيادة", + "release_from_incident": "إخلاء من الحادث", + "remove_from_lane": "إزالة من المسار", "reopen_button": "إعادة فتح القيادة", "reopen_error": "فشل في إعادة فتح القيادة", "reopen_message": "كان لهذه المكالمة قيادة سابقة انتهت. أعد فتحها للمتابعة بسجلها، أو ابدأ قيادة جديدة.", @@ -637,7 +659,14 @@ "resource_name_label": "المورد", "resource_name_placeholder": "مثال: مضخة 3، سلم 1، فريق الدعم", "resource_person": "شخص خارجي", + "resource_position_label": "آخر موقع معروف", + "resource_position_unknown": "لا يوجد تحديث موقع حديث", + "resource_roles_label": "الأدوار", + "resource_staffing_label": "التشكيل", + "resource_status_label": "الحالة", + "resource_status_unknown": "غير معروف", "resource_unit": "وحدة / فريق", + "resource_unit_roles_label": "الطاقم", "resources_section": "الموارد", "role_finance": "المالية/الإدارة", "role_ic": "قائد الحادث", @@ -663,7 +692,7 @@ "secondary_lead_short": "ثانوي", "secondary_objective_label": "الهدف الثانوي", "selected": "محدد", - "show_more": "عرض المزيد", + "send_message": "إرسال الرسالة", "staging_location_label": "منطقة التجمع", "start_command": "بدء القيادة", "start_command_description": "اختر قالبًا أو ابدأ بلوحة فارغة — يمكن للقوالب تمثيل هياكل ICS أو خدمات الأمن أو خطط التوظيف لفعالية كمهرجان أو حفل.", @@ -694,9 +723,13 @@ "transmission_log": "سجل الإرسال", "transmission_seconds": "{{count}} ث", "unassigned": "غير معيّن", + "unit_details_title": "تفاصيل الوحدة", "unit_eta": "الوصول المتوقع {{eta}}", "unit_roles_count": "الأدوار {{filled}}/{{total}}", "view_call": "عرض البلاغ", + "view_details": "عرض التفاصيل", + "view_log": "عرض السجل", + "view_on_map": "عرض على الخريطة", "voice_section": "قنوات الصوت" }, "common": { @@ -989,6 +1022,26 @@ "search": "البحث في الملاحظات...", "title": "الملاحظات" }, + "notifications": { + "additional_info": "معلومات إضافية", + "confirm_delete_message_one": "هل أنت متأكد من حذف {{count}} إشعار؟ لا يمكن التراجع عن هذا الإجراء.", + "confirm_delete_message_other": "هل أنت متأكد من حذف {{count}} إشعارات؟ لا يمكن التراجع عن هذا الإجراء.", + "confirm_delete_title": "تأكيد الحذف", + "delete_error": "فشل حذف الإشعارات", + "delete_one_error": "فشل حذف الإشعار", + "delete_one_success": "تم حذف الإشعار", + "delete_partial": "تم حذف {{succeeded}}، فشل {{failed}}", + "delete_success_one": "تم حذف {{count}} إشعار", + "delete_success_other": "تم حذف {{count}} إشعارات", + "deselect_all": "إلغاء تحديد الكل", + "empty": "لا توجد تحديثات متاحة", + "load_error": "تعذر تحميل الإشعارات", + "notification": "إشعار", + "select_all": "تحديد الكل", + "selected_count": "{{count}} محدد", + "title": "الإشعارات", + "view_reference": "عرض {{type}}" + }, "offline": { "offline_message": "غير متصل — يتم عرض آخر البيانات المتزامنة", "offline_with_pending": "غير متصل — ستتم مزامنة {{count}} تغيير(ات) عند استعادة الاتصال", diff --git a/src/translations/de.json b/src/translations/de.json index aaa612b..ea8646d 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -372,8 +372,10 @@ "added_to_command": "Zum Führungsboard hinzugefügt", "agency_label": "Firma / Organisation (optional)", "agency_placeholder": "z. B. Acme Security, Rotes Kreuz", + "all": "Alle", "assign": "Zuweisen", "assign_resource": "Ressource zuweisen", + "assigned": "Zugewiesen", "assignment_label": "Auftrag / Standort", "assignment_placeholder": "z. B. Innenangriff, Abschnitt A", "channel_connected": "Verbunden", @@ -386,6 +388,11 @@ "command_details": "Führungsdetails", "crew_placeholder": "Name der Person oder des Trupps", "current_commander": "EL", + "delete_lane_confirm": "Spur löschen", + "delete_lane_message": "Diese Spur löschen? Dies kann nicht rückgängig gemacht werden.", + "delete_lane_move_resources": "Zu Ressourcen verschieben", + "delete_lane_release_resources": "Vom Einsatz lösen", + "delete_lane_resources_message": "Dieser Spur sind {{count}} Ressourcen zugewiesen. Zurück in die Ressourcenliste verschieben oder vom Einsatz lösen.", "drag_move_hint": "Halten Sie eine Ressource gedrückt, um sie zu ziehen, oder tippen Sie sie und anschließend eine Spur an.", "edit_lane": "Abschnitt bearbeiten", "edit_lane_title": "{{lane}} bearbeiten", @@ -466,6 +473,7 @@ "incident_weather_none": "Keine aktiven Wetterwarnungen für diesen Standort", "incident_weather_section": "Wetterwarnungen", "incident_weather_view": "Anzeigen", + "include_deputies": "Auch an stellvertretende Einsatzleiter senden", "info_save_error": "Speichern der Einsatzinformationen fehlgeschlagen", "info_save_success": "Einsatzinformationen gespeichert", "lane_color_label": "Spurfarbe (optional — färbt die Marker dieser Spur auf der Karte)", @@ -504,7 +512,18 @@ "linked_need_label": "Verknüpfter Bedarf", "location_geocode_hint": "Kein Pin gesetzt — der Text wird beim Speichern geocodiert", "location_placeholder": "Ort beschreiben (Adresse, Orientierungspunkt, ...)", + "log_entries_count": "{{count}} Einträge", + "log_no_results": "Keine passenden Protokolleinträge", + "log_results_count": "{{shown}} von {{total}} Einträgen", + "log_search_placeholder": "Protokolleinträge durchsuchen", "map_command_section": "Führungsboard", + "message_body_placeholder": "Nachricht", + "message_commander": "Nachricht an Einsatzleiter", + "message_commander_hint": "Geht direkt an den Einsatzleiter über dessen Benachrichtigungskanäle.", + "message_commander_hint_named": "Geht direkt an {{name}} über dessen Benachrichtigungskanäle.", + "message_send_error": "Nachricht konnte nicht gesendet werden", + "message_send_success": "Nachricht an Einsatzleitung gesendet", + "message_subject_placeholder": "Betreff (optional)", "move": "Verschieben", "move_conflict_message": "{{resource}} ist bereits {{lane}} zugewiesen. Nach {{target}} verschieben?", "move_conflict_title": "Ressource bereits zugewiesen", @@ -609,6 +628,7 @@ "par_ok": "PAR OK", "par_pending": "PAR ausstehend", "par_warning": "Warnung", + "person_details_title": "Personendetails", "person_label": "Name", "person_name_placeholder": "Name der Person", "person_placeholder": "Person für diese Rolle", @@ -621,6 +641,8 @@ "provisional_badge": "Offline — Synchronisierung ausstehend", "rehab_location_label": "Erholungsbereich", "release_from_command": "Aus der Führung entlassen", + "release_from_incident": "Vom Einsatz lösen", + "remove_from_lane": "Aus Spur entfernen", "reopen_button": "Einsatzleitung wieder öffnen", "reopen_error": "Wiedereröffnen der Einsatzleitung fehlgeschlagen", "reopen_message": "Dieser Einsatz hatte eine frühere, beendete Einsatzleitung. Öffnen Sie sie wieder, um mit ihrem Verlauf fortzufahren, oder starten Sie eine neue.", @@ -637,7 +659,14 @@ "resource_name_label": "Ressource", "resource_name_placeholder": "z. B. LF 3, DLK 1, Überlandhilfe", "resource_person": "Externe Person", + "resource_position_label": "Letzte Position", + "resource_position_unknown": "Keine aktuelle Standortaktualisierung", + "resource_roles_label": "Rollen", + "resource_staffing_label": "Besetzung", + "resource_status_label": "Status", + "resource_status_unknown": "Unbekannt", "resource_unit": "Einheit / Trupp", + "resource_unit_roles_label": "Besatzung", "resources_section": "Ressourcen", "role_finance": "Finanzen/Verwaltung", "role_ic": "Einsatzleiter", @@ -663,7 +692,7 @@ "secondary_lead_short": "Stv.", "secondary_objective_label": "Sekundäres Ziel", "selected": "Ausgewählt", - "show_more": "Mehr anzeigen", + "send_message": "Nachricht senden", "staging_location_label": "Bereitstellungsraum", "start_command": "Führung starten", "start_command_description": "Wähle eine Vorlage oder starte leer — Vorlagen können ICS-Strukturen, Sicherheitsdienste oder Personalpläne für Feste und Konzerte abbilden.", @@ -694,9 +723,13 @@ "transmission_log": "Sendeprotokoll", "transmission_seconds": "{{count}}s", "unassigned": "Nicht zugewiesen", + "unit_details_title": "Einheitendetails", "unit_eta": "ETA {{eta}}", "unit_roles_count": "Rollen {{filled}}/{{total}}", "view_call": "Einsatz anzeigen", + "view_details": "Details anzeigen", + "view_log": "Protokoll anzeigen", + "view_on_map": "Auf Karte anzeigen", "voice_section": "Sprachkanäle" }, "common": { @@ -989,6 +1022,26 @@ "search": "Notizen suchen...", "title": "Notizen" }, + "notifications": { + "additional_info": "Zusätzliche Informationen", + "confirm_delete_message_one": "Möchtest du {{count}} Benachrichtigung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", + "confirm_delete_message_other": "Möchtest du {{count}} Benachrichtigungen wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", + "confirm_delete_title": "Löschen bestätigen", + "delete_error": "Benachrichtigungen konnten nicht entfernt werden", + "delete_one_error": "Benachrichtigung konnte nicht entfernt werden", + "delete_one_success": "Benachrichtigung entfernt", + "delete_partial": "{{succeeded}} entfernt, {{failed}} fehlgeschlagen", + "delete_success_one": "{{count}} Benachrichtigung entfernt", + "delete_success_other": "{{count}} Benachrichtigungen entfernt", + "deselect_all": "Alle abwählen", + "empty": "Keine Aktualisierungen verfügbar", + "load_error": "Benachrichtigungen können nicht geladen werden", + "notification": "Benachrichtigung", + "select_all": "Alle auswählen", + "selected_count": "{{count}} ausgewählt", + "title": "Benachrichtigungen", + "view_reference": "{{type}} anzeigen" + }, "offline": { "offline_message": "Offline — letzte synchronisierte Daten werden angezeigt", "offline_with_pending": "Offline — {{count}} Änderung(en) werden bei Verbindung synchronisiert", diff --git a/src/translations/en.json b/src/translations/en.json index abc8024..b6dd72d 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -372,8 +372,10 @@ "added_to_command": "Added to the command board", "agency_label": "Company / Agency (optional)", "agency_placeholder": "e.g. Acme Security, Red Cross", + "all": "All", "assign": "Assign", "assign_resource": "Assign Resource", + "assigned": "Assigned", "assignment_label": "Assignment / Location", "assignment_placeholder": "e.g. Interior attack, Division A", "channel_connected": "Connected", @@ -386,6 +388,11 @@ "command_details": "Command Details", "crew_placeholder": "Person or crew name", "current_commander": "IC", + "delete_lane_confirm": "Delete Lane", + "delete_lane_message": "Delete this lane? This cannot be undone.", + "delete_lane_move_resources": "Move to Resources", + "delete_lane_release_resources": "Release from Incident", + "delete_lane_resources_message": "This lane has {{count}} assigned resources. Move them back to the resources list or release them from the incident.", "drag_move_hint": "Press and hold a resource to drag it, or tap it and then tap a lane.", "edit_lane": "Edit Lane", "edit_lane_title": "Edit {{lane}}", @@ -466,6 +473,7 @@ "incident_weather_none": "No active weather alerts for this location", "incident_weather_section": "Weather Alerts", "incident_weather_view": "View", + "include_deputies": "Also send to Deputy Commanders", "info_save_error": "Failed to save incident information", "info_save_success": "Incident information saved", "lane_color_label": "Lane Color (optional — colors this lane’s markers on the map)", @@ -504,7 +512,18 @@ "linked_need_label": "Linked Need", "location_geocode_hint": "No pin set — the text will be geocoded on save", "location_placeholder": "Describe the location (address, landmark, ...)", + "log_entries_count": "{{count}} entries", + "log_no_results": "No matching log entries", + "log_results_count": "{{shown}} of {{total}} entries", + "log_search_placeholder": "Search log entries", "map_command_section": "Command Board", + "message_body_placeholder": "Message", + "message_commander": "Message Commander", + "message_commander_hint": "Goes directly to the incident commander via their notification channels.", + "message_commander_hint_named": "Goes directly to {{name}} via their notification channels.", + "message_send_error": "Failed to send message", + "message_send_success": "Message sent to command", + "message_subject_placeholder": "Subject (optional)", "move": "Move", "move_conflict_message": "{{resource}} is already assigned to {{lane}}. Move it to {{target}}?", "move_conflict_title": "Resource Already Assigned", @@ -609,6 +628,7 @@ "par_ok": "PAR OK", "par_pending": "PAR Pending", "par_warning": "Warning", + "person_details_title": "Person Details", "person_label": "Name", "person_name_placeholder": "Person's name", "person_placeholder": "Person assigned to this role", @@ -621,6 +641,8 @@ "provisional_badge": "Offline — pending sync", "rehab_location_label": "Rehab", "release_from_command": "Release from Command", + "release_from_incident": "Release from Incident", + "remove_from_lane": "Remove from Lane", "reopen_button": "Reopen Command", "reopen_error": "Failed to reopen command", "reopen_message": "This call had a previous command that has ended. Reopen it to continue with its history, or start a new command.", @@ -637,7 +659,14 @@ "resource_name_label": "Resource", "resource_name_placeholder": "e.g. Engine 3, Ladder 1, Mutual aid crew", "resource_person": "External Person", + "resource_position_label": "Recent Position", + "resource_position_unknown": "No recent location update", + "resource_roles_label": "Roles", + "resource_staffing_label": "Staffing", + "resource_status_label": "Status", + "resource_status_unknown": "Unknown", "resource_unit": "Unit / Crew", + "resource_unit_roles_label": "Crew", "resources_section": "Resources", "role_finance": "Finance/Admin", "role_ic": "Incident Commander", @@ -663,7 +692,7 @@ "secondary_lead_short": "2nd", "secondary_objective_label": "Secondary Objective", "selected": "Selected", - "show_more": "Show more", + "send_message": "Send Message", "staging_location_label": "Staging", "start_command": "Start Command", "start_command_description": "Pick a template or start blank — templates can model ICS structures, security details, or event staffing plans like a fair or concert.", @@ -694,9 +723,13 @@ "transmission_log": "Transmission Log", "transmission_seconds": "{{count}}s", "unassigned": "Unassigned", + "unit_details_title": "Unit Details", "unit_eta": "ETA {{eta}}", "unit_roles_count": "Roles {{filled}}/{{total}}", "view_call": "View Call", + "view_details": "View Details", + "view_log": "View Log", + "view_on_map": "View on Map", "voice_section": "Voice Channels" }, "common": { @@ -989,6 +1022,26 @@ "search": "Search notes...", "title": "Notes" }, + "notifications": { + "additional_info": "Additional Information", + "confirm_delete_message_one": "Are you sure you want to delete {{count}} notification? This action cannot be undone.", + "confirm_delete_message_other": "Are you sure you want to delete {{count}} notifications? This action cannot be undone.", + "confirm_delete_title": "Confirm Delete", + "delete_error": "Failed to remove notifications", + "delete_one_error": "Failed to remove notification", + "delete_one_success": "Notification removed", + "delete_partial": "{{succeeded}} removed, {{failed}} failed", + "delete_success_one": "{{count}} notification removed", + "delete_success_other": "{{count}} notifications removed", + "deselect_all": "Deselect All", + "empty": "No updates available", + "load_error": "Unable to load notifications", + "notification": "Notification", + "select_all": "Select All", + "selected_count": "{{count}} selected", + "title": "Notifications", + "view_reference": "View {{type}}" + }, "offline": { "offline_message": "Offline — showing last synced data", "offline_with_pending": "Offline — {{count}} change(s) will sync when reconnected", diff --git a/src/translations/es.json b/src/translations/es.json index 4075404..8f0e1ee 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -372,8 +372,10 @@ "added_to_command": "Añadido al tablero de mando", "agency_label": "Empresa / Agencia (opcional)", "agency_placeholder": "p. ej. Seguridad Acme, Cruz Roja", + "all": "Todos", "assign": "Asignar", "assign_resource": "Asignar recurso", + "assigned": "Asignados", "assignment_label": "Asignación / Ubicación", "assignment_placeholder": "p. ej. Ataque interior, División A", "channel_connected": "Conectado", @@ -386,6 +388,11 @@ "command_details": "Detalles del comando", "crew_placeholder": "Nombre de la persona o del equipo", "current_commander": "CI", + "delete_lane_confirm": "Eliminar carril", + "delete_lane_message": "¿Eliminar este carril? Esta acción no se puede deshacer.", + "delete_lane_move_resources": "Mover a recursos", + "delete_lane_release_resources": "Dar de baja del incidente", + "delete_lane_resources_message": "Este carril tiene {{count}} recursos asignados. Muévalos a la lista de recursos o délos de baja del incidente.", "drag_move_hint": "Mantén pulsado un recurso para arrastrarlo, o tócalo y luego toca un carril.", "edit_lane": "Editar carril", "edit_lane_title": "Editar {{lane}}", @@ -466,6 +473,7 @@ "incident_weather_none": "Sin alertas meteorológicas activas para esta ubicación", "incident_weather_section": "Alertas meteorológicas", "incident_weather_view": "Ver", + "include_deputies": "Enviar también a los comandantes adjuntos", "info_save_error": "No se pudo guardar la información", "info_save_success": "Información guardada", "lane_color_label": "Color del carril (opcional — colorea sus marcadores en el mapa)", @@ -504,7 +512,18 @@ "linked_need_label": "Necesidad vinculada", "location_geocode_hint": "Sin pin: el texto se geocodificará al guardar", "location_placeholder": "Describe la ubicación (dirección, referencia, ...)", + "log_entries_count": "{{count}} entradas", + "log_no_results": "No hay entradas coincidentes", + "log_results_count": "{{shown}} de {{total}} entradas", + "log_search_placeholder": "Buscar en el registro", "map_command_section": "Tablero de mando", + "message_body_placeholder": "Mensaje", + "message_commander": "Enviar mensaje al comandante", + "message_commander_hint": "Se envía directamente al comandante del incidente por sus canales de notificación.", + "message_commander_hint_named": "Se envía directamente a {{name}} por sus canales de notificación.", + "message_send_error": "Error al enviar el mensaje", + "message_send_success": "Mensaje enviado al mando", + "message_subject_placeholder": "Asunto (opcional)", "move": "Mover", "move_conflict_message": "{{resource}} ya está asignado a {{lane}}. ¿Moverlo a {{target}}?", "move_conflict_title": "Recurso ya asignado", @@ -609,6 +628,7 @@ "par_ok": "PAR OK", "par_pending": "PAR pendiente", "par_warning": "Advertencia", + "person_details_title": "Detalles de la persona", "person_label": "Nombre", "person_name_placeholder": "Nombre de la persona", "person_placeholder": "Persona asignada a este rol", @@ -621,6 +641,8 @@ "provisional_badge": "Sin conexión — pendiente de sincronizar", "rehab_location_label": "Rehabilitación", "release_from_command": "Liberar del mando", + "release_from_incident": "Dar de baja del incidente", + "remove_from_lane": "Quitar del carril", "reopen_button": "Reabrir comando", "reopen_error": "No se pudo reabrir el comando", "reopen_message": "Esta llamada tuvo un comando anterior que finalizó. Reábrelo para continuar con su historial o inicia un nuevo comando.", @@ -637,7 +659,14 @@ "resource_name_label": "Recurso", "resource_name_placeholder": "p. ej. Bomba 3, Escalera 1, Equipo de ayuda mutua", "resource_person": "Persona externa", + "resource_position_label": "Última posición", + "resource_position_unknown": "Sin actualización de ubicación reciente", + "resource_roles_label": "Roles", + "resource_staffing_label": "Dotación", + "resource_status_label": "Estado", + "resource_status_unknown": "Desconocido", "resource_unit": "Unidad / Equipo", + "resource_unit_roles_label": "Tripulación", "resources_section": "Recursos", "role_finance": "Finanzas/Administración", "role_ic": "Comandante del incidente", @@ -663,7 +692,7 @@ "secondary_lead_short": "2.º", "secondary_objective_label": "Objetivo secundario", "selected": "Seleccionado", - "show_more": "Mostrar más", + "send_message": "Enviar mensaje", "staging_location_label": "Área de espera", "start_command": "Iniciar comando", "start_command_description": "Elige una plantilla o comienza en blanco: las plantillas pueden modelar estructuras ICS, dispositivos de seguridad o dotaciones para eventos como ferias o conciertos.", @@ -694,9 +723,13 @@ "transmission_log": "Registro de transmisiones", "transmission_seconds": "{{count}}s", "unassigned": "Sin asignar", + "unit_details_title": "Detalles de la unidad", "unit_eta": "ETA {{eta}}", "unit_roles_count": "Roles {{filled}}/{{total}}", "view_call": "Ver llamada", + "view_details": "Ver detalles", + "view_log": "Ver registro", + "view_on_map": "Ver en el mapa", "voice_section": "Canales de voz" }, "common": { @@ -989,6 +1022,26 @@ "search": "Buscar notas...", "title": "Notas" }, + "notifications": { + "additional_info": "Información adicional", + "confirm_delete_message_one": "¿Seguro que quieres eliminar {{count}} notificación? Esta acción no se puede deshacer.", + "confirm_delete_message_other": "¿Seguro que quieres eliminar {{count}} notificaciones? Esta acción no se puede deshacer.", + "confirm_delete_title": "Confirmar eliminación", + "delete_error": "No se pudieron eliminar las notificaciones", + "delete_one_error": "No se pudo eliminar la notificación", + "delete_one_success": "Notificación eliminada", + "delete_partial": "{{succeeded}} eliminadas, {{failed}} con error", + "delete_success_one": "{{count}} notificación eliminada", + "delete_success_other": "{{count}} notificaciones eliminadas", + "deselect_all": "Deseleccionar todo", + "empty": "No hay actualizaciones disponibles", + "load_error": "No se pueden cargar las notificaciones", + "notification": "Notificación", + "select_all": "Seleccionar todo", + "selected_count": "{{count}} seleccionadas", + "title": "Notificaciones", + "view_reference": "Ver {{type}}" + }, "offline": { "offline_message": "Sin conexión — mostrando los últimos datos sincronizados", "offline_with_pending": "Sin conexión — {{count}} cambio(s) se sincronizarán al reconectar", diff --git a/src/translations/fr.json b/src/translations/fr.json index 30db513..1428c18 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -372,8 +372,10 @@ "added_to_command": "Ajouté au tableau de commandement", "agency_label": "Société / Organisme (facultatif)", "agency_placeholder": "ex. Sécurité Acme, Croix-Rouge", + "all": "Tous", "assign": "Affecter", "assign_resource": "Affecter une ressource", + "assigned": "Affectés", "assignment_label": "Affectation / Emplacement", "assignment_placeholder": "ex. Attaque intérieure, Division A", "channel_connected": "Connecté", @@ -386,6 +388,11 @@ "command_details": "Détails du commandement", "crew_placeholder": "Nom de la personne ou de l'équipe", "current_commander": "CI", + "delete_lane_confirm": "Supprimer la voie", + "delete_lane_message": "Supprimer cette voie ? Cette action est irréversible.", + "delete_lane_move_resources": "Déplacer vers les ressources", + "delete_lane_release_resources": "Libérer de l'incident", + "delete_lane_resources_message": "Cette voie compte {{count}} ressources affectées. Déplacez-les vers la liste des ressources ou libérez-les de l'incident.", "drag_move_hint": "Appuyez longuement sur une ressource pour la faire glisser, ou touchez-la puis touchez une voie.", "edit_lane": "Modifier la voie", "edit_lane_title": "Modifier {{lane}}", @@ -466,6 +473,7 @@ "incident_weather_none": "Aucune alerte météo active pour cet emplacement", "incident_weather_section": "Alertes météo", "incident_weather_view": "Voir", + "include_deputies": "Envoyer aussi aux commandants adjoints", "info_save_error": "Échec de l'enregistrement des informations", "info_save_success": "Informations enregistrées", "lane_color_label": "Couleur de la voie (optionnel — colore ses marqueurs sur la carte)", @@ -504,7 +512,18 @@ "linked_need_label": "Besoin lié", "location_geocode_hint": "Aucun repère — le texte sera géocodé à l'enregistrement", "location_placeholder": "Décrivez l'emplacement (adresse, repère, ...)", + "log_entries_count": "{{count}} entrées", + "log_no_results": "Aucune entrée correspondante", + "log_results_count": "{{shown}} sur {{total}} entrées", + "log_search_placeholder": "Rechercher dans le journal", "map_command_section": "Tableau de commandement", + "message_body_placeholder": "Message", + "message_commander": "Message au commandant", + "message_commander_hint": "Envoyé directement au commandant de l'incident via ses canaux de notification.", + "message_commander_hint_named": "Envoyé directement à {{name}} via ses canaux de notification.", + "message_send_error": "Échec de l'envoi du message", + "message_send_success": "Message envoyé au commandement", + "message_subject_placeholder": "Sujet (facultatif)", "move": "Déplacer", "move_conflict_message": "{{resource}} est déjà affecté à {{lane}}. Le déplacer vers {{target}} ?", "move_conflict_title": "Ressource déjà affectée", @@ -609,6 +628,7 @@ "par_ok": "PAR OK", "par_pending": "PAR en attente", "par_warning": "Alerte", + "person_details_title": "Détails de la personne", "person_label": "Nom", "person_name_placeholder": "Nom de la personne", "person_placeholder": "Personne affectée à ce rôle", @@ -621,6 +641,8 @@ "provisional_badge": "Hors ligne — en attente de synchronisation", "rehab_location_label": "Réhabilitation", "release_from_command": "Libérer du commandement", + "release_from_incident": "Libérer de l'incident", + "remove_from_lane": "Retirer de la voie", "reopen_button": "Rouvrir le commandement", "reopen_error": "Échec de la réouverture du commandement", "reopen_message": "Cet appel avait un commandement précédent qui est terminé. Rouvrez-le pour poursuivre avec son historique, ou démarrez un nouveau commandement.", @@ -637,7 +659,14 @@ "resource_name_label": "Ressource", "resource_name_placeholder": "ex. Engin 3, Échelle 1, Équipe d'entraide", "resource_person": "Personne externe", + "resource_position_label": "Dernière position", + "resource_position_unknown": "Aucune mise à jour de position récente", + "resource_roles_label": "Rôles", + "resource_staffing_label": "Effectif", + "resource_status_label": "Statut", + "resource_status_unknown": "Inconnu", "resource_unit": "Unité / Équipe", + "resource_unit_roles_label": "Équipage", "resources_section": "Ressources", "role_finance": "Finances/Administration", "role_ic": "Commandant de l'incident", @@ -663,7 +692,7 @@ "secondary_lead_short": "2e", "secondary_objective_label": "Objectif secondaire", "selected": "Sélectionné", - "show_more": "Afficher plus", + "send_message": "Envoyer le message", "staging_location_label": "Zone d'attente", "start_command": "Démarrer le commandement", "start_command_description": "Choisissez un modèle ou partez de zéro — les modèles peuvent représenter des structures ICS, des dispositifs de sécurité ou des plans d'effectifs pour une foire ou un concert.", @@ -694,9 +723,13 @@ "transmission_log": "Journal des transmissions", "transmission_seconds": "{{count}}s", "unassigned": "Non affecté", + "unit_details_title": "Détails de l'unité", "unit_eta": "ETA {{eta}}", "unit_roles_count": "Rôles {{filled}}/{{total}}", "view_call": "Voir l'appel", + "view_details": "Voir les détails", + "view_log": "Voir le journal", + "view_on_map": "Voir sur la carte", "voice_section": "Canaux vocaux" }, "common": { @@ -989,6 +1022,26 @@ "search": "Rechercher des notes...", "title": "Notes" }, + "notifications": { + "additional_info": "Informations supplémentaires", + "confirm_delete_message_one": "Voulez-vous vraiment supprimer {{count}} notification ? Cette action est irréversible.", + "confirm_delete_message_other": "Voulez-vous vraiment supprimer {{count}} notifications ? Cette action est irréversible.", + "confirm_delete_title": "Confirmer la suppression", + "delete_error": "Échec de la suppression des notifications", + "delete_one_error": "Échec de la suppression de la notification", + "delete_one_success": "Notification supprimée", + "delete_partial": "{{succeeded}} supprimées, {{failed}} en échec", + "delete_success_one": "{{count}} notification supprimée", + "delete_success_other": "{{count}} notifications supprimées", + "deselect_all": "Tout désélectionner", + "empty": "Aucune mise à jour disponible", + "load_error": "Impossible de charger les notifications", + "notification": "Notification", + "select_all": "Tout sélectionner", + "selected_count": "{{count}} sélectionnée(s)", + "title": "Notifications", + "view_reference": "Voir {{type}}" + }, "offline": { "offline_message": "Hors ligne — affichage des dernières données synchronisées", "offline_with_pending": "Hors ligne — {{count}} modification(s) seront synchronisées à la reconnexion", diff --git a/src/translations/it.json b/src/translations/it.json index da60d5a..eeb8c2f 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -372,8 +372,10 @@ "added_to_command": "Aggiunto alla board di comando", "agency_label": "Azienda / Ente (facoltativo)", "agency_placeholder": "es. Acme Security, Croce Rossa", + "all": "Tutti", "assign": "Assegna", "assign_resource": "Assegna risorsa", + "assigned": "Assegnati", "assignment_label": "Incarico / Posizione", "assignment_placeholder": "es. Attacco interno, Divisione A", "channel_connected": "Connesso", @@ -386,6 +388,11 @@ "command_details": "Dettagli del comando", "crew_placeholder": "Nome della persona o della squadra", "current_commander": "CI", + "delete_lane_confirm": "Elimina corsia", + "delete_lane_message": "Eliminare questa corsia? L'azione è irreversibile.", + "delete_lane_move_resources": "Sposta in Risorse", + "delete_lane_release_resources": "Rilascia dall'incidente", + "delete_lane_resources_message": "Questa corsia ha {{count}} risorse assegnate. Spostale nell'elenco delle risorse o rilasciale dall'incidente.", "drag_move_hint": "Tieni premuta una risorsa per trascinarla, oppure toccala e poi tocca una corsia.", "edit_lane": "Modifica corsia", "edit_lane_title": "Modifica {{lane}}", @@ -466,6 +473,7 @@ "incident_weather_none": "Nessuna allerta meteo attiva per questa posizione", "incident_weather_section": "Allerte meteo", "incident_weather_view": "Vedi", + "include_deputies": "Invia anche ai vice comandanti", "info_save_error": "Salvataggio delle informazioni non riuscito", "info_save_success": "Informazioni salvate", "lane_color_label": "Colore corsia (opzionale — colora i suoi marker sulla mappa)", @@ -504,7 +512,18 @@ "linked_need_label": "Necessità collegata", "location_geocode_hint": "Nessun pin: il testo sarà geocodificato al salvataggio", "location_placeholder": "Descrivi la posizione (indirizzo, punto di riferimento, ...)", + "log_entries_count": "{{count}} voci", + "log_no_results": "Nessuna voce corrispondente", + "log_results_count": "{{shown}} di {{total}} voci", + "log_search_placeholder": "Cerca nel registro", "map_command_section": "Board di comando", + "message_body_placeholder": "Messaggio", + "message_commander": "Messaggio al comandante", + "message_commander_hint": "Inviato direttamente al comandante dell'incidente tramite i suoi canali di notifica.", + "message_commander_hint_named": "Inviato direttamente a {{name}} tramite i suoi canali di notifica.", + "message_send_error": "Invio del messaggio non riuscito", + "message_send_success": "Messaggio inviato al comando", + "message_subject_placeholder": "Oggetto (facoltativo)", "move": "Sposta", "move_conflict_message": "{{resource}} è già assegnato a {{lane}}. Spostarlo in {{target}}?", "move_conflict_title": "Risorsa già assegnata", @@ -609,6 +628,7 @@ "par_ok": "PAR OK", "par_pending": "PAR in attesa", "par_warning": "Avviso", + "person_details_title": "Dettagli persona", "person_label": "Nome", "person_name_placeholder": "Nome della persona", "person_placeholder": "Persona assegnata a questo ruolo", @@ -621,6 +641,8 @@ "provisional_badge": "Offline — in attesa di sincronizzazione", "rehab_location_label": "Riabilitazione", "release_from_command": "Rilascia dal comando", + "release_from_incident": "Rilascia dall'incidente", + "remove_from_lane": "Rimuovi dalla corsia", "reopen_button": "Riapri il comando", "reopen_error": "Riapertura del comando non riuscita", "reopen_message": "Questa chiamata aveva un comando precedente terminato. Riaprilo per continuare con il suo storico oppure avvia un nuovo comando.", @@ -637,7 +659,14 @@ "resource_name_label": "Risorsa", "resource_name_placeholder": "es. APS 3, Autoscala 1, Squadra di supporto", "resource_person": "Persona esterna", + "resource_position_label": "Ultima posizione", + "resource_position_unknown": "Nessun aggiornamento di posizione recente", + "resource_roles_label": "Ruoli", + "resource_staffing_label": "Disponibilità", + "resource_status_label": "Stato", + "resource_status_unknown": "Sconosciuto", "resource_unit": "Unità / Squadra", + "resource_unit_roles_label": "Equipaggio", "resources_section": "Risorse", "role_finance": "Finanze/Amministrazione", "role_ic": "Comandante dell'incidente", @@ -663,7 +692,7 @@ "secondary_lead_short": "2°", "secondary_objective_label": "Obiettivo secondario", "selected": "Selezionato", - "show_more": "Mostra altro", + "send_message": "Invia messaggio", "staging_location_label": "Area di raccolta", "start_command": "Avvia comando", "start_command_description": "Scegli un modello o parti da zero: i modelli possono rappresentare strutture ICS, servizi di sicurezza o piani del personale per fiere e concerti.", @@ -694,9 +723,13 @@ "transmission_log": "Registro trasmissioni", "transmission_seconds": "{{count}}s", "unassigned": "Non assegnato", + "unit_details_title": "Dettagli unità", "unit_eta": "ETA {{eta}}", "unit_roles_count": "Ruoli {{filled}}/{{total}}", "view_call": "Vedi chiamata", + "view_details": "Vedi dettagli", + "view_log": "Visualizza registro", + "view_on_map": "Vedi sulla mappa", "voice_section": "Canali vocali" }, "common": { @@ -989,6 +1022,26 @@ "search": "Cerca note...", "title": "Note" }, + "notifications": { + "additional_info": "Informazioni aggiuntive", + "confirm_delete_message_one": "Sei sicuro di voler eliminare {{count}} notifica? L'azione è irreversibile.", + "confirm_delete_message_other": "Sei sicuro di voler eliminare {{count}} notifiche? L'azione è irreversibile.", + "confirm_delete_title": "Conferma eliminazione", + "delete_error": "Impossibile rimuovere le notifiche", + "delete_one_error": "Impossibile rimuovere la notifica", + "delete_one_success": "Notifica rimossa", + "delete_partial": "{{succeeded}} rimosse, {{failed}} non riuscite", + "delete_success_one": "{{count}} notifica rimossa", + "delete_success_other": "{{count}} notifiche rimosse", + "deselect_all": "Deseleziona tutto", + "empty": "Nessun aggiornamento disponibile", + "load_error": "Impossibile caricare le notifiche", + "notification": "Notifica", + "select_all": "Seleziona tutto", + "selected_count": "{{count}} selezionate", + "title": "Notifiche", + "view_reference": "Vedi {{type}}" + }, "offline": { "offline_message": "Offline — visualizzazione degli ultimi dati sincronizzati", "offline_with_pending": "Offline — {{count}} modifiche verranno sincronizzate alla riconnessione", diff --git a/src/translations/pl.json b/src/translations/pl.json index 6f7f79d..12adf1f 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -372,8 +372,10 @@ "added_to_command": "Dodano do tablicy dowodzenia", "agency_label": "Firma / Organizacja (opcjonalnie)", "agency_placeholder": "np. Acme Security, Czerwony Krzyż", + "all": "Wszystkie", "assign": "Przydziel", "assign_resource": "Przydziel zasób", + "assigned": "Przydzielone", "assignment_label": "Przydział / Lokalizacja", "assignment_placeholder": "np. Natarcie wewnętrzne, Odcinek A", "channel_connected": "Połączono", @@ -386,6 +388,11 @@ "command_details": "Szczegóły dowodzenia", "crew_placeholder": "Nazwa osoby lub zastępu", "current_commander": "KD", + "delete_lane_confirm": "Usuń sekcję", + "delete_lane_message": "Usunąć tę sekcję? Tej operacji nie można cofnąć.", + "delete_lane_move_resources": "Przenieś do zasobów", + "delete_lane_release_resources": "Zwolnij z incydentu", + "delete_lane_resources_message": "Ta sekcja ma {{count}} przydzielonych zasobów. Przenieś je z powrotem do listy zasobów lub zwolnij z incydentu.", "drag_move_hint": "Naciśnij i przytrzymaj zasób, aby go przeciągnąć, albo dotknij go, a następnie dotknij pasa.", "edit_lane": "Edytuj odcinek", "edit_lane_title": "Edytuj {{lane}}", @@ -466,6 +473,7 @@ "incident_weather_none": "Brak aktywnych alertów pogodowych dla tej lokalizacji", "incident_weather_section": "Alerty pogodowe", "incident_weather_view": "Zobacz", + "include_deputies": "Wyślij również do zastępców dowodzącego", "info_save_error": "Nie udało się zapisać informacji", "info_save_success": "Zapisano informacje", "lane_color_label": "Kolor pasa (opcjonalnie — koloruje jego znaczniki na mapie)", @@ -504,7 +512,18 @@ "linked_need_label": "Powiązana potrzeba", "location_geocode_hint": "Brak pinezki — tekst zostanie geokodowany przy zapisie", "location_placeholder": "Opisz lokalizację (adres, punkt orientacyjny, ...)", + "log_entries_count": "Wpisy: {{count}}", + "log_no_results": "Brak pasujących wpisów", + "log_results_count": "{{shown}} z {{total}} wpisów", + "log_search_placeholder": "Szukaj w dzienniku", "map_command_section": "Tablica dowodzenia", + "message_body_placeholder": "Wiadomość", + "message_commander": "Wiadomość do dowodzącego", + "message_commander_hint": "Trafia bezpośrednio do dowodzącego akcją przez jego kanały powiadomień.", + "message_commander_hint_named": "Trafia bezpośrednio do {{name}} przez jego kanały powiadomień.", + "message_send_error": "Nie udało się wysłać wiadomości", + "message_send_success": "Wiadomość wysłana do dowodzenia", + "message_subject_placeholder": "Temat (opcjonalnie)", "move": "Przenieś", "move_conflict_message": "{{resource}} jest już przypisany do {{lane}}. Przenieść do {{target}}?", "move_conflict_title": "Zasób już przypisany", @@ -609,6 +628,7 @@ "par_ok": "PAR OK", "par_pending": "PAR oczekuje", "par_warning": "Ostrzeżenie", + "person_details_title": "Szczegóły osoby", "person_label": "Imię i nazwisko", "person_name_placeholder": "Imię i nazwisko osoby", "person_placeholder": "Osoba przydzielona do tej roli", @@ -621,6 +641,8 @@ "provisional_badge": "Offline — oczekuje na synchronizację", "rehab_location_label": "Strefa odpoczynku", "release_from_command": "Zwolnij z dowodzenia", + "release_from_incident": "Zwolnij z incydentu", + "remove_from_lane": "Usuń z sekcji", "reopen_button": "Wznów dowodzenie", "reopen_error": "Nie udało się wznowić dowodzenia", "reopen_message": "To wezwanie miało wcześniejsze, zakończone dowodzenie. Wznów je, aby kontynuować z jego historią, albo rozpocznij nowe.", @@ -637,7 +659,14 @@ "resource_name_label": "Zasób", "resource_name_placeholder": "np. GBA 3, Drabina 1, Zastęp wsparcia", "resource_person": "Osoba zewnętrzna", + "resource_position_label": "Ostatnia pozycja", + "resource_position_unknown": "Brak ostatniej aktualizacji lokalizacji", + "resource_roles_label": "Role", + "resource_staffing_label": "Obsada", + "resource_status_label": "Status", + "resource_status_unknown": "Nieznany", "resource_unit": "Jednostka / Zastęp", + "resource_unit_roles_label": "Załoga", "resources_section": "Zasoby", "role_finance": "Finanse/Administracja", "role_ic": "Kierujący działaniem ratowniczym", @@ -663,7 +692,7 @@ "secondary_lead_short": "Zast.", "secondary_objective_label": "Cel dodatkowy", "selected": "Wybrano", - "show_more": "Pokaż więcej", + "send_message": "Wyślij wiadomość", "staging_location_label": "Punkt koncentracji", "start_command": "Rozpocznij dowodzenie", "start_command_description": "Wybierz szablon lub zacznij od pustej tablicy — szablony mogą odwzorowywać struktury ICS, ochronę obiektu lub obsadę wydarzeń takich jak festyn czy koncert.", @@ -694,9 +723,13 @@ "transmission_log": "Dziennik transmisji", "transmission_seconds": "{{count}}s", "unassigned": "Nieprzypisane", + "unit_details_title": "Szczegóły jednostki", "unit_eta": "ETA {{eta}}", "unit_roles_count": "Role {{filled}}/{{total}}", "view_call": "Zobacz zgłoszenie", + "view_details": "Zobacz szczegóły", + "view_log": "Zobacz dziennik", + "view_on_map": "Zobacz na mapie", "voice_section": "Kanały głosowe" }, "common": { @@ -989,6 +1022,26 @@ "search": "Szukaj notatek...", "title": "Notatki" }, + "notifications": { + "additional_info": "Dodatkowe informacje", + "confirm_delete_message_one": "Czy na pewno usunąć {{count}} powiadomienie? Tej operacji nie można cofnąć.", + "confirm_delete_message_other": "Czy na pewno usunąć {{count}} powiadomienia? Tej operacji nie można cofnąć.", + "confirm_delete_title": "Potwierdź usunięcie", + "delete_error": "Nie udało się usunąć powiadomień", + "delete_one_error": "Nie udało się usunąć powiadomienia", + "delete_one_success": "Powiadomienie usunięte", + "delete_partial": "Usunięto: {{succeeded}}, niepowodzenie: {{failed}}", + "delete_success_one": "Usunięto {{count}} powiadomienie", + "delete_success_other": "Usunięto {{count}} powiadomienia", + "deselect_all": "Odznacz wszystko", + "empty": "Brak dostępnych aktualizacji", + "load_error": "Nie można załadować powiadomień", + "notification": "Powiadomienie", + "select_all": "Zaznacz wszystko", + "selected_count": "Wybrano: {{count}}", + "title": "Powiadomienia", + "view_reference": "Zobacz {{type}}" + }, "offline": { "offline_message": "Offline — wyświetlane są ostatnio zsynchronizowane dane", "offline_with_pending": "Offline — {{count}} zmian(y) zsynchronizują się po połączeniu", diff --git a/src/translations/sv.json b/src/translations/sv.json index c30c5b3..684867c 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -372,8 +372,10 @@ "added_to_command": "Tillagd på ledningstavlan", "agency_label": "Företag / Organisation (valfritt)", "agency_placeholder": "t.ex. Acme Security, Röda Korset", + "all": "Alla", "assign": "Tilldela", "assign_resource": "Tilldela resurs", + "assigned": "Tilldelade", "assignment_label": "Uppgift / Plats", "assignment_placeholder": "t.ex. Invändig släckning, Sektor A", "channel_connected": "Ansluten", @@ -386,6 +388,11 @@ "command_details": "Ledningsdetaljer", "crew_placeholder": "Namn på person eller styrka", "current_commander": "IL", + "delete_lane_confirm": "Ta bort körfält", + "delete_lane_message": "Ta bort detta körfält? Detta kan inte ångras.", + "delete_lane_move_resources": "Flytta till resurser", + "delete_lane_release_resources": "Frigör från insatsen", + "delete_lane_resources_message": "Detta körfält har {{count}} tilldelade resurser. Flytta dem tillbaka till resurslistan eller frigör dem från insatsen.", "drag_move_hint": "Tryck och håll på en resurs för att dra den, eller tryck på den och sedan på en bana.", "edit_lane": "Redigera sektor", "edit_lane_title": "Redigera {{lane}}", @@ -466,6 +473,7 @@ "incident_weather_none": "Inga aktiva vädervarningar för platsen", "incident_weather_section": "Vädervarningar", "incident_weather_view": "Visa", + "include_deputies": "Skicka även till ställföreträdande chefer", "info_save_error": "Det gick inte att spara informationen", "info_save_success": "Informationen sparad", "lane_color_label": "Filfärg (valfritt — färgar filens markörer på kartan)", @@ -504,7 +512,18 @@ "linked_need_label": "Kopplat behov", "location_geocode_hint": "Ingen nål — texten geokodas vid sparande", "location_placeholder": "Beskriv platsen (adress, landmärke, ...)", + "log_entries_count": "{{count}} poster", + "log_no_results": "Inga matchande loggposter", + "log_results_count": "{{shown}} av {{total}} poster", + "log_search_placeholder": "Sök i loggen", "map_command_section": "Ledningstavla", + "message_body_placeholder": "Meddelande", + "message_commander": "Meddelande till insatschef", + "message_commander_hint": "Skickas direkt till insatschefen via dennes aviseringskanaler.", + "message_commander_hint_named": "Skickas direkt till {{name}} via dennes aviseringskanaler.", + "message_send_error": "Det gick inte att skicka meddelandet", + "message_send_success": "Meddelande skickat till ledningen", + "message_subject_placeholder": "Ämne (valfritt)", "move": "Flytta", "move_conflict_message": "{{resource}} är redan tilldelad {{lane}}. Flytta till {{target}}?", "move_conflict_title": "Resursen är redan tilldelad", @@ -609,6 +628,7 @@ "par_ok": "PAR OK", "par_pending": "PAR väntar", "par_warning": "Varning", + "person_details_title": "Persondetaljer", "person_label": "Namn", "person_name_placeholder": "Personens namn", "person_placeholder": "Person som tilldelats rollen", @@ -621,6 +641,8 @@ "provisional_badge": "Offline — väntar på synkronisering", "rehab_location_label": "Återhämtning", "release_from_command": "Släpp från ledning", + "release_from_incident": "Frigör från insatsen", + "remove_from_lane": "Ta bort från körfältet", "reopen_button": "Återöppna ledning", "reopen_error": "Det gick inte att återöppna ledningen", "reopen_message": "Detta larm hade en tidigare ledning som avslutats. Återöppna den för att fortsätta med dess historik, eller starta en ny ledning.", @@ -637,7 +659,14 @@ "resource_name_label": "Resurs", "resource_name_placeholder": "t.ex. Släckbil 3, Stegbil 1, Förstärkningsstyrka", "resource_person": "Extern person", + "resource_position_label": "Senaste position", + "resource_position_unknown": "Ingen sen platsuppdatering", + "resource_roles_label": "Roller", + "resource_staffing_label": "Bemanning", + "resource_status_label": "Status", + "resource_status_unknown": "Okänd", "resource_unit": "Enhet / Styrka", + "resource_unit_roles_label": "Besättning", "resources_section": "Resurser", "role_finance": "Ekonomi/Administration", "role_ic": "Insatschef", @@ -663,7 +692,7 @@ "secondary_lead_short": "Bitr.", "secondary_objective_label": "Sekundärt mål", "selected": "Vald", - "show_more": "Visa fler", + "send_message": "Skicka meddelande", "staging_location_label": "Uppställningsplats", "start_command": "Starta ledning", "start_command_description": "Välj en mall eller börja tomt — mallar kan beskriva ICS-strukturer, bevakningsuppdrag eller bemanningsplaner för evenemang som mässor och konserter.", @@ -694,9 +723,13 @@ "transmission_log": "Sändningslogg", "transmission_seconds": "{{count}}s", "unassigned": "Ej tilldelad", + "unit_details_title": "Enhetsdetaljer", "unit_eta": "ETA {{eta}}", "unit_roles_count": "Roller {{filled}}/{{total}}", "view_call": "Visa larm", + "view_details": "Visa detaljer", + "view_log": "Visa logg", + "view_on_map": "Visa på karta", "voice_section": "Röstkanaler" }, "common": { @@ -989,6 +1022,26 @@ "search": "Sök anteckningar...", "title": "Anteckningar" }, + "notifications": { + "additional_info": "Ytterligare information", + "confirm_delete_message_one": "Vill du ta bort {{count}} avisering? Detta kan inte ångras.", + "confirm_delete_message_other": "Vill du ta bort {{count}} aviseringar? Detta kan inte ångras.", + "confirm_delete_title": "Bekräfta borttagning", + "delete_error": "Det gick inte att ta bort aviseringarna", + "delete_one_error": "Det gick inte att ta bort aviseringen", + "delete_one_success": "Avisering borttagen", + "delete_partial": "{{succeeded}} borttagna, {{failed}} misslyckades", + "delete_success_one": "{{count}} avisering borttagen", + "delete_success_other": "{{count}} aviseringar borttagna", + "deselect_all": "Avmarkera alla", + "empty": "Inga uppdateringar tillgängliga", + "load_error": "Det går inte att läsa in aviseringar", + "notification": "Avisering", + "select_all": "Markera alla", + "selected_count": "{{count}} valda", + "title": "Aviseringar", + "view_reference": "Visa {{type}}" + }, "offline": { "offline_message": "Offline — visar senast synkroniserade data", "offline_with_pending": "Offline — {{count}} ändring(ar) synkroniseras vid återanslutning", diff --git a/src/translations/uk.json b/src/translations/uk.json index 78b59c9..870039b 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -372,8 +372,10 @@ "added_to_command": "Додано до дошки командування", "agency_label": "Компанія / Організація (необовʼязково)", "agency_placeholder": "напр. Acme Security, Червоний Хрест", + "all": "Усі", "assign": "Призначити", "assign_resource": "Призначити ресурс", + "assigned": "Призначені", "assignment_label": "Завдання / Розташування", "assignment_placeholder": "напр. Внутрішня атака, Ділянка А", "channel_connected": "Підключено", @@ -386,6 +388,11 @@ "command_details": "Деталі управління", "crew_placeholder": "Ім'я особи або назва ланки", "current_commander": "КР", + "delete_lane_confirm": "Видалити смугу", + "delete_lane_message": "Видалити цю смугу? Цю дію неможливо скасувати.", + "delete_lane_move_resources": "Перемістити до ресурсів", + "delete_lane_release_resources": "Звільнити з інциденту", + "delete_lane_resources_message": "Ця смуга має {{count}} призначених ресурсів. Поверніть їх до списку ресурсів або звільніть з інциденту.", "drag_move_hint": "Натисніть і утримуйте ресурс, щоб перетягнути його, або торкніться ресурсу, а потім смуги.", "edit_lane": "Редагувати ділянку", "edit_lane_title": "Редагувати {{lane}}", @@ -466,6 +473,7 @@ "incident_weather_none": "Немає активних погодних сповіщень для цієї локації", "incident_weather_section": "Погодні сповіщення", "incident_weather_view": "Переглянути", + "include_deputies": "Також надіслати заступникам керівника", "info_save_error": "Не вдалося зберегти інформацію", "info_save_success": "Інформацію збережено", "lane_color_label": "Колір смуги (необов’язково — фарбує її маркери на мапі)", @@ -504,7 +512,18 @@ "linked_need_label": "Пов'язана потреба", "location_geocode_hint": "Пін не встановлено — текст буде геокодовано під час збереження", "location_placeholder": "Опишіть місце (адреса, орієнтир, ...)", + "log_entries_count": "Записів: {{count}}", + "log_no_results": "Немає відповідних записів", + "log_results_count": "{{shown}} з {{total}} записів", + "log_search_placeholder": "Пошук у журналі", "map_command_section": "Дошка командування", + "message_body_placeholder": "Повідомлення", + "message_commander": "Повідомлення керівнику", + "message_commander_hint": "Надсилається безпосередньо керівнику інциденту через його канали сповіщень.", + "message_commander_hint_named": "Надсилається безпосередньо {{name}} через канали сповіщень.", + "message_send_error": "Не вдалося надіслати повідомлення", + "message_send_success": "Повідомлення надіслано керівництву", + "message_subject_placeholder": "Тема (необов'язково)", "move": "Перемістити", "move_conflict_message": "{{resource}} уже призначено до {{lane}}. Перемістити до {{target}}?", "move_conflict_title": "Ресурс уже призначено", @@ -609,6 +628,7 @@ "par_ok": "PAR OK", "par_pending": "PAR очікує", "par_warning": "Попередження", + "person_details_title": "Деталі особи", "person_label": "Ім'я", "person_name_placeholder": "Імʼя особи", "person_placeholder": "Особа, призначена на цю роль", @@ -621,6 +641,8 @@ "provisional_badge": "Офлайн — очікує синхронізації", "rehab_location_label": "Зона відновлення", "release_from_command": "Звільнити з командування", + "release_from_incident": "Звільнити з інциденту", + "remove_from_lane": "Прибрати зі смуги", "reopen_button": "Відновити командування", "reopen_error": "Не вдалося відновити командування", "reopen_message": "Цей виклик мав попереднє командування, яке завершилося. Відновіть його, щоб продовжити з історією, або розпочніть нове.", @@ -637,7 +659,14 @@ "resource_name_label": "Ресурс", "resource_name_placeholder": "напр. Автоцистерна 3, Драбина 1, Ланка підтримки", "resource_person": "Зовнішня особа", + "resource_position_label": "Остання позиція", + "resource_position_unknown": "Немає останнього оновлення місцезнаходження", + "resource_roles_label": "Ролі", + "resource_staffing_label": "Штат", + "resource_status_label": "Статус", + "resource_status_unknown": "Невідомо", "resource_unit": "Підрозділ / Ланка", + "resource_unit_roles_label": "Екіпаж", "resources_section": "Ресурси", "role_finance": "Фінанси/Адміністрація", "role_ic": "Керівник гасіння", @@ -663,7 +692,7 @@ "secondary_lead_short": "Заст.", "secondary_objective_label": "Додаткова ціль", "selected": "Вибрано", - "show_more": "Показати більше", + "send_message": "Надіслати повідомлення", "staging_location_label": "Зона очікування", "start_command": "Розпочати управління", "start_command_description": "Виберіть шаблон або почніть з чистої дошки — шаблони можуть описувати структури ICS, охорону обʼєкта чи штат для ярмарку або концерту.", @@ -694,9 +723,13 @@ "transmission_log": "Журнал передач", "transmission_seconds": "{{count}}с", "unassigned": "Не призначено", + "unit_details_title": "Деталі підрозділу", "unit_eta": "Прибуття {{eta}}", "unit_roles_count": "Ролі {{filled}}/{{total}}", "view_call": "Переглянути виклик", + "view_details": "Переглянути деталі", + "view_log": "Переглянути журнал", + "view_on_map": "Переглянути на карті", "voice_section": "Голосові канали" }, "common": { @@ -989,6 +1022,26 @@ "search": "Пошук приміток...", "title": "Примітки" }, + "notifications": { + "additional_info": "Додаткова інформація", + "confirm_delete_message_one": "Видалити {{count}} сповіщення? Цю дію неможливо скасувати.", + "confirm_delete_message_other": "Видалити {{count}} сповіщення? Цю дію неможливо скасувати.", + "confirm_delete_title": "Підтвердити видалення", + "delete_error": "Не вдалося видалити сповіщення", + "delete_one_error": "Не вдалося видалити сповіщення", + "delete_one_success": "Сповіщення видалено", + "delete_partial": "Видалено: {{succeeded}}, помилок: {{failed}}", + "delete_success_one": "Видалено {{count}} сповіщення", + "delete_success_other": "Видалено {{count}} сповіщень", + "deselect_all": "Зняти вибір", + "empty": "Немає доступних оновлень", + "load_error": "Не вдалося завантажити сповіщення", + "notification": "Сповіщення", + "select_all": "Вибрати все", + "selected_count": "Вибрано: {{count}}", + "title": "Сповіщення", + "view_reference": "Переглянути {{type}}" + }, "offline": { "offline_message": "Офлайн — показано останні синхронізовані дані", "offline_with_pending": "Офлайн — {{count}} змін(и) синхронізуються після відновлення зв’язку", diff --git a/src/types/notification.ts b/src/types/notification.ts index 0559b9e..dbcb1c5 100644 --- a/src/types/notification.ts +++ b/src/types/notification.ts @@ -7,7 +7,7 @@ export interface NotificationPayload { type?: string; referenceId?: string; referenceType?: 'call' | 'message' | 'status' | 'note' | 'other'; - metadata?: Record; + metadata?: Record; } export interface NotificationDetailProps {