diff --git a/.gitignore b/.gitignore index aff770a..0d0cf1c 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,7 @@ Gemfile expo-env.d.ts # @end expo-cli .dual-graph/ +/.dual-graph-pro +.mcp.json +/.codex +/.claude diff --git a/app.config.ts b/app.config.ts index 555945f..9716373 100644 --- a/app.config.ts +++ b/app.config.ts @@ -151,6 +151,26 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ 'assets/audio/unitstatusupdated.wav', 'assets/audio/upcomingshift.wav', 'assets/audio/upcomingtraining.wav', + 'assets/audio/modernnotification.wav', + 'assets/audio/moderncallclosed.wav', + 'assets/audio/moderncallupdated.wav', + 'assets/audio/moderncallemergency.wav', + 'assets/audio/moderncallhigh.wav', + 'assets/audio/moderncalllow.wav', + 'assets/audio/moderncallmedium.wav', + 'assets/audio/modernchat.wav', + 'assets/audio/modernmessage.wav', + 'assets/audio/modernshift.wav', + 'assets/audio/moderntraining.wav', + 'assets/audio/modernpersonnelstatus.wav', + 'assets/audio/modernstaffing.wav', + 'assets/audio/moderntroublealert.wav', + 'assets/audio/modernunitnotice.wav', + 'assets/audio/modernunitstatus.wav', + 'assets/audio/modernavailabilityalert.wav', + 'assets/audio/moderncalendar.wav', + 'assets/audio/modernresourceorder.wav', + 'assets/audio/modernweatheralert.wav', 'assets/audio/custom/c1.wav', 'assets/audio/custom/c2.wav', 'assets/audio/custom/c3.wav', diff --git a/assets/audio/modernavailabilityalert.wav b/assets/audio/modernavailabilityalert.wav new file mode 100644 index 0000000..5331ae5 Binary files /dev/null and b/assets/audio/modernavailabilityalert.wav differ diff --git a/assets/audio/moderncalendar.wav b/assets/audio/moderncalendar.wav new file mode 100644 index 0000000..20227b4 Binary files /dev/null and b/assets/audio/moderncalendar.wav differ diff --git a/assets/audio/moderncallclosed.wav b/assets/audio/moderncallclosed.wav new file mode 100644 index 0000000..8ad4b4b Binary files /dev/null and b/assets/audio/moderncallclosed.wav differ diff --git a/assets/audio/moderncallemergency.wav b/assets/audio/moderncallemergency.wav new file mode 100644 index 0000000..1e1ef4f Binary files /dev/null and b/assets/audio/moderncallemergency.wav differ diff --git a/assets/audio/moderncallhigh.wav b/assets/audio/moderncallhigh.wav new file mode 100644 index 0000000..b8af461 Binary files /dev/null and b/assets/audio/moderncallhigh.wav differ diff --git a/assets/audio/moderncalllow.wav b/assets/audio/moderncalllow.wav new file mode 100644 index 0000000..cf5bd9b Binary files /dev/null and b/assets/audio/moderncalllow.wav differ diff --git a/assets/audio/moderncallmedium.wav b/assets/audio/moderncallmedium.wav new file mode 100644 index 0000000..7aba780 Binary files /dev/null and b/assets/audio/moderncallmedium.wav differ diff --git a/assets/audio/moderncallupdated.wav b/assets/audio/moderncallupdated.wav new file mode 100644 index 0000000..68884d8 Binary files /dev/null and b/assets/audio/moderncallupdated.wav differ diff --git a/assets/audio/modernchat.wav b/assets/audio/modernchat.wav new file mode 100644 index 0000000..9bbc0b8 Binary files /dev/null and b/assets/audio/modernchat.wav differ diff --git a/assets/audio/modernmessage.wav b/assets/audio/modernmessage.wav new file mode 100644 index 0000000..68b22e6 Binary files /dev/null and b/assets/audio/modernmessage.wav differ diff --git a/assets/audio/modernnotification.wav b/assets/audio/modernnotification.wav new file mode 100644 index 0000000..54956cb Binary files /dev/null and b/assets/audio/modernnotification.wav differ diff --git a/assets/audio/modernpersonnelstatus.wav b/assets/audio/modernpersonnelstatus.wav new file mode 100644 index 0000000..372eb3b Binary files /dev/null and b/assets/audio/modernpersonnelstatus.wav differ diff --git a/assets/audio/modernresourceorder.wav b/assets/audio/modernresourceorder.wav new file mode 100644 index 0000000..090abee Binary files /dev/null and b/assets/audio/modernresourceorder.wav differ diff --git a/assets/audio/modernshift.wav b/assets/audio/modernshift.wav new file mode 100644 index 0000000..a09a804 Binary files /dev/null and b/assets/audio/modernshift.wav differ diff --git a/assets/audio/modernstaffing.wav b/assets/audio/modernstaffing.wav new file mode 100644 index 0000000..f97044c Binary files /dev/null and b/assets/audio/modernstaffing.wav differ diff --git a/assets/audio/moderntraining.wav b/assets/audio/moderntraining.wav new file mode 100644 index 0000000..8ecf1c4 Binary files /dev/null and b/assets/audio/moderntraining.wav differ diff --git a/assets/audio/moderntroublealert.wav b/assets/audio/moderntroublealert.wav new file mode 100644 index 0000000..573bcac Binary files /dev/null and b/assets/audio/moderntroublealert.wav differ diff --git a/assets/audio/modernunitnotice.wav b/assets/audio/modernunitnotice.wav new file mode 100644 index 0000000..aeb0bb5 Binary files /dev/null and b/assets/audio/modernunitnotice.wav differ diff --git a/assets/audio/modernunitstatus.wav b/assets/audio/modernunitstatus.wav new file mode 100644 index 0000000..1305d1e Binary files /dev/null and b/assets/audio/modernunitstatus.wav differ diff --git a/assets/audio/modernweatheralert.wav b/assets/audio/modernweatheralert.wav new file mode 100644 index 0000000..a8a7e3f Binary files /dev/null and b/assets/audio/modernweatheralert.wav differ diff --git a/src/api/calls/calls.ts b/src/api/calls/calls.ts index 766bd2f..baaa15c 100644 --- a/src/api/calls/calls.ts +++ b/src/api/calls/calls.ts @@ -13,6 +13,8 @@ const getCallExtraDataApi = createApiEndpoint('/Calls/GetCallExtraData'); const createCallApi = createApiEndpoint('/Calls/SaveCall'); const updateCallApi = createApiEndpoint('/Calls/EditCall'); const closeCallApi = createApiEndpoint('/Calls/CloseCall'); +const deleteCallApi = createApiEndpoint('/Calls/DeleteCall'); +const updateScheduledDispatchTimeApi = createApiEndpoint('/Calls/UpdateScheduledDispatchTime'); export const getCalls = async () => { // Add timestamp to prevent any caching @@ -89,6 +91,10 @@ export interface UpdateCallRequest { externalId?: string; referenceId?: string; destinationPoiId?: number | null; + /** When true, re-sends the dispatch to all currently dispatched entities. */ + rebroadcastCall?: boolean; + /** When true, notifies entities that were removed from the dispatch list on this edit. */ + notifyCancelledEntities?: boolean; } export interface CloseCallRequest { @@ -189,6 +195,8 @@ export const updateCall = async (callData: UpdateCallRequest) => { IncidentId: callData.linkedCallId || '', ExternalId: callData.externalId || '', ReferenceId: callData.referenceId || '', + RebroadcastCall: callData.rebroadcastCall ?? false, + NotifyCancelledEntities: callData.notifyCancelledEntities ?? false, }; const response = await updateCallApi.put(data); @@ -205,3 +213,28 @@ export const closeCall = async (callData: CloseCallRequest) => { const response = await closeCallApi.put(data); return response.data; }; + +/** Soft-deletes a call. The server only allows deleting a call that has not yet been dispatched. */ +export const deleteCall = async (callId: string) => { + const response = await deleteCallApi.delete({ + callId: encodeURIComponent(callId), + }); + return response.data; +}; + +export interface UpdateScheduledDispatchTimeRequest { + callId: string; + /** Department-local dispatch date/time (ISO string). */ + date: string; +} + +/** Reschedules the dispatch time of a not-yet-dispatched scheduled call. */ +export const updateScheduledDispatchTime = async (request: UpdateScheduledDispatchTimeRequest) => { + const data = { + Id: request.callId, + Date: request.date, + }; + + const response = await updateScheduledDispatchTimeApi.put(data); + return response.data; +}; diff --git a/src/api/checkIn/checkInTimers.ts b/src/api/checkIn/checkInTimers.ts index 98eb80f..4bffc9a 100644 --- a/src/api/checkIn/checkInTimers.ts +++ b/src/api/checkIn/checkInTimers.ts @@ -1,3 +1,4 @@ +import { type CallPersonnelCheckInStatusResult } from '@/models/v4/checkIn/callPersonnelCheckInStatusResult'; import { type CheckInRecordResult } from '@/models/v4/checkIn/checkInRecordResult'; import { type CheckInTimerStatusResult } from '@/models/v4/checkIn/checkInTimerStatusResult'; import { type PerformCheckInResult } from '@/models/v4/checkIn/performCheckInResult'; @@ -10,6 +11,7 @@ const getTimersForCallApi = createApiEndpoint('/CheckInTimers/GetTimersForCall') const performCheckInApi = createApiEndpoint('/CheckInTimers/PerformCheckIn'); const getCheckInHistoryApi = createApiEndpoint('/CheckInTimers/GetCheckInHistory'); const toggleCallTimersApi = createApiEndpoint('/CheckInTimers/ToggleCallTimers'); +const getCallPersonnelCheckInStatusesApi = createApiEndpoint('/CheckInTimers/GetCallPersonnelCheckInStatuses'); export const getTimerStatuses = async (callId: number) => { const response = await getTimerStatusesApi.get({ callId }); @@ -77,3 +79,9 @@ export const toggleCallTimers = async (callId: number, enabled: boolean) => { const response = await toggleCallTimersApi.put({ callId, enabled }); return response.data; }; + +/** Per-personnel accountability (PAR) roster for a call. */ +export const getCallPersonnelCheckInStatuses = async (callId: number) => { + const response = await getCallPersonnelCheckInStatusesApi.get({ callId }); + return response.data; +}; diff --git a/src/api/incidentCommand/commands.ts b/src/api/incidentCommand/commands.ts new file mode 100644 index 0000000..ade9f06 --- /dev/null +++ b/src/api/incidentCommand/commands.ts @@ -0,0 +1,31 @@ +import { createApiEndpoint } from '@/api/common/client'; +import { type CommandResult, type CommandsResult, type SaveCommandInput } from '@/models/v4/incidentCommand/commandDefinition'; + +const seg = (value: string | number) => encodeURIComponent(String(value)); + +/** All command definitions (templates) for the department. */ +export const getAllCommands = async () => { + const response = await createApiEndpoint('/Commands/GetAllCommands').get(); + return response.data; +}; + +export const getCommand = async (commandDefinitionId: number) => { + const response = await createApiEndpoint(`/Commands/GetCommand/${seg(commandDefinitionId)}`).get(); + return response.data; +}; + +/** Resolve the template for a call type (pass 0 for "Any Call Type"; the server falls back to it). */ +export const getCommandForCallType = async (callTypeId: number) => { + const response = await createApiEndpoint(`/Commands/GetCommandForCallType/${seg(callTypeId)}`).get(); + return response.data; +}; + +export const saveCommand = async (input: SaveCommandInput) => { + const response = await createApiEndpoint('/Commands/SaveCommand').post({ ...input }); + return response.data; +}; + +export const deleteCommand = async (commandDefinitionId: number) => { + const response = await createApiEndpoint(`/Commands/DeleteCommand/${seg(commandDefinitionId)}`).delete(); + return response.data; +}; diff --git a/src/api/incidentCommand/incidentCommand.ts b/src/api/incidentCommand/incidentCommand.ts new file mode 100644 index 0000000..9faa0bc --- /dev/null +++ b/src/api/incidentCommand/incidentCommand.ts @@ -0,0 +1,132 @@ +import { createApiEndpoint } from '@/api/common/client'; +import { type CommandAccountabilityResult, type EvaluateAccountabilityResult } from '@/models/v4/incidentCommand/accountability'; +import { type CommandLogEntry, type CommandTimelineResult } from '@/models/v4/incidentCommand/commandLog'; +import { type CommandNodeResult, type CommandStructureNode } from '@/models/v4/incidentCommand/commandStructureNode'; +import { + type CommandTransferResult, + type EstablishCommandInput, + type IncidentCommandActionResult, + type IncidentCommandResult, + type TransferCommandInput, + type UpdateActionPlanInput, +} from '@/models/v4/incidentCommand/incidentCommand'; +import { type IncidentCommandBoardResult } from '@/models/v4/incidentCommand/incidentCommandBoard'; +import { type IncidentMapAnnotation, type IncidentMapAnnotationResult } from '@/models/v4/incidentCommand/incidentMapAnnotation'; +import { type IncidentTimer, type IncidentTimerResult } from '@/models/v4/incidentCommand/incidentTimer'; +import { type MoveResourceInput, type ResourceAssignment, type ResourceAssignmentResult } from '@/models/v4/incidentCommand/resourceAssignment'; +import { type TacticalObjective, type TacticalObjectiveResult } from '@/models/v4/incidentCommand/tacticalObjective'; + +const seg = (value: string | number) => encodeURIComponent(String(value)); + +// --- Command lifecycle ----------------------------------------------------- + +export const establishCommand = async (input: EstablishCommandInput) => { + const response = await createApiEndpoint('/IncidentCommand/EstablishCommand').post({ ...input }); + return response.data; +}; + +export const getCommandBoard = async (callId: string) => { + const response = await createApiEndpoint(`/IncidentCommand/GetCommandBoard/${seg(callId)}`).get(); + return response.data; +}; + +export const transferCommand = async (input: TransferCommandInput) => { + const response = await createApiEndpoint('/IncidentCommand/TransferCommand').post({ ...input }); + return response.data; +}; + +export const closeCommand = async (incidentCommandId: string) => { + const response = await createApiEndpoint(`/IncidentCommand/CloseCommand/${seg(incidentCommandId)}`).put({}); + return response.data; +}; + +export const updateActionPlan = async (input: UpdateActionPlanInput) => { + const response = await createApiEndpoint('/IncidentCommand/UpdateActionPlan').put({ ...input }); + return response.data; +}; + +// --- Accountability (PAR) -------------------------------------------------- + +export const getAccountability = async (callId: string) => { + const response = await createApiEndpoint(`/IncidentCommand/GetAccountability/${seg(callId)}`).get(); + return response.data; +}; + +export const evaluateAccountability = async (callId: string) => { + const response = await createApiEndpoint(`/IncidentCommand/EvaluateAccountability/${seg(callId)}`).post({}); + return response.data; +}; + +// --- Command structure (org chart lanes) ----------------------------------- + +export const saveNode = async (node: Partial) => { + const response = await createApiEndpoint('/IncidentCommand/SaveNode').post({ ...node }); + return response.data; +}; + +export const deleteNode = async (commandStructureNodeId: string) => { + const response = await createApiEndpoint(`/IncidentCommand/DeleteNode/${seg(commandStructureNodeId)}`).delete(); + return response.data; +}; + +// --- Resource assignments --------------------------------------------------- + +export const assignResource = async (assignment: Partial) => { + const response = await createApiEndpoint('/IncidentCommand/AssignResource').post({ ...assignment }); + return response.data; +}; + +export const moveResource = async (input: MoveResourceInput) => { + const response = await createApiEndpoint('/IncidentCommand/MoveResource').post({ ...input }); + return response.data; +}; + +export const releaseResource = async (resourceAssignmentId: string) => { + const response = await createApiEndpoint(`/IncidentCommand/ReleaseResource/${seg(resourceAssignmentId)}`).post({}); + return response.data; +}; + +// --- Tactical objectives ---------------------------------------------------- + +export const saveObjective = async (objective: Partial) => { + const response = await createApiEndpoint('/IncidentCommand/SaveObjective').post({ ...objective }); + return response.data; +}; + +export const completeObjective = async (tacticalObjectiveId: string) => { + const response = await createApiEndpoint(`/IncidentCommand/CompleteObjective/${seg(tacticalObjectiveId)}`).post({}); + return response.data; +}; + +// --- Timers ----------------------------------------------------------------- + +export const startTimer = async (timer: Partial) => { + const response = await createApiEndpoint('/IncidentCommand/StartTimer').post({ ...timer }); + return response.data; +}; + +export const acknowledgeTimer = async (incidentTimerId: string) => { + const response = await createApiEndpoint(`/IncidentCommand/AcknowledgeTimer/${seg(incidentTimerId)}`).post({}); + return response.data; +}; + +// --- Map annotations -------------------------------------------------------- + +export const saveAnnotation = async (annotation: Partial) => { + const response = await createApiEndpoint('/IncidentCommand/SaveAnnotation').post({ ...annotation }); + return response.data; +}; + +export const deleteAnnotation = async (incidentMapAnnotationId: string) => { + const response = await createApiEndpoint(`/IncidentCommand/DeleteAnnotation/${seg(incidentMapAnnotationId)}`).delete(); + return response.data; +}; + +// --- Timeline --------------------------------------------------------------- + +export const getTimeline = async (callId: string): Promise => { + const response = await createApiEndpoint(`/IncidentCommand/GetTimeline/${seg(callId)}`).get(); + return response.data; +}; + +export type { CommandLogEntry }; diff --git a/src/api/incidentCommand/incidentReporting.ts b/src/api/incidentCommand/incidentReporting.ts new file mode 100644 index 0000000..35d722a --- /dev/null +++ b/src/api/incidentCommand/incidentReporting.ts @@ -0,0 +1,14 @@ +import { createApiEndpoint } from '@/api/common/client'; +import { type IncidentAfterActionReportResult, type IncidentReportSummaryResult } from '@/models/v4/incidentCommand/incidentReport'; + +const seg = (value: string | number) => encodeURIComponent(String(value)); + +export const getIncidentSummary = async (callId: string) => { + const response = await createApiEndpoint(`/IncidentReporting/GetIncidentSummary/${seg(callId)}`).get(); + return response.data; +}; + +export const getAfterActionReport = async (callId: string) => { + const response = await createApiEndpoint(`/IncidentReporting/GetAfterActionReport/${seg(callId)}`).get(); + return response.data; +}; diff --git a/src/api/incidentCommand/incidentResources.ts b/src/api/incidentCommand/incidentResources.ts new file mode 100644 index 0000000..eda4c62 --- /dev/null +++ b/src/api/incidentCommand/incidentResources.ts @@ -0,0 +1,54 @@ +import { createApiEndpoint } from '@/api/common/client'; +import { + type AdHocPersonnelListResult, + type AdHocPersonnelResult, + type AdHocUnitResult, + type AdHocUnitsResult, + type AssignPersonnelToUnitInput, + type FormUnitInput, + type IncidentAdHocPersonnel, + type IncidentAdHocUnit, +} from '@/models/v4/incidentCommand/incidentAdHocResources'; +import { type IncidentCommandActionResult } from '@/models/v4/incidentCommand/incidentCommand'; + +const seg = (value: string | number) => encodeURIComponent(String(value)); + +export const createAdHocUnit = async (unit: Partial) => { + const response = await createApiEndpoint('/IncidentResources/CreateAdHocUnit').post({ ...unit }); + return response.data; +}; + +export const getAdHocUnits = async (callId: string) => { + const response = await createApiEndpoint(`/IncidentResources/GetAdHocUnits/${seg(callId)}`).get(); + return response.data; +}; + +export const releaseAdHocUnit = async (incidentAdHocUnitId: string) => { + const response = await createApiEndpoint(`/IncidentResources/ReleaseAdHocUnit/${seg(incidentAdHocUnitId)}`).post({}); + return response.data; +}; + +export const createAdHocPersonnel = async (person: Partial) => { + const response = await createApiEndpoint('/IncidentResources/CreateAdHocPersonnel').post({ ...person }); + return response.data; +}; + +export const getAdHocPersonnel = async (callId: string) => { + const response = await createApiEndpoint(`/IncidentResources/GetAdHocPersonnel/${seg(callId)}`).get(); + return response.data; +}; + +export const releaseAdHocPersonnel = async (incidentAdHocPersonnelId: string) => { + const response = await createApiEndpoint(`/IncidentResources/ReleaseAdHocPersonnel/${seg(incidentAdHocPersonnelId)}`).post({}); + return response.data; +}; + +export const assignPersonnelToUnit = async (input: AssignPersonnelToUnitInput) => { + const response = await createApiEndpoint('/IncidentResources/AssignPersonnelToUnit').post({ ...input }); + return response.data; +}; + +export const formUnit = async (input: FormUnitInput) => { + const response = await createApiEndpoint('/IncidentResources/FormUnit').post({ ...input }); + return response.data; +}; diff --git a/src/api/incidentCommand/incidentRoles.ts b/src/api/incidentCommand/incidentRoles.ts new file mode 100644 index 0000000..9379758 --- /dev/null +++ b/src/api/incidentCommand/incidentRoles.ts @@ -0,0 +1,26 @@ +import { createApiEndpoint } from '@/api/common/client'; +import { type IncidentCommandActionResult } from '@/models/v4/incidentCommand/incidentCommand'; +import { type IncidentCapabilitiesResult, type IncidentRoleAssignment, type IncidentRoleResult, type IncidentRolesResult } from '@/models/v4/incidentCommand/incidentRole'; + +const seg = (value: string | number) => encodeURIComponent(String(value)); + +export const assignRole = async (assignment: Partial) => { + const response = await createApiEndpoint('/IncidentRoles/AssignRole').post({ ...assignment }); + return response.data; +}; + +export const removeRole = async (incidentRoleAssignmentId: string) => { + const response = await createApiEndpoint(`/IncidentRoles/RemoveRole/${seg(incidentRoleAssignmentId)}`).post({}); + return response.data; +}; + +export const getRoles = async (callId: string) => { + const response = await createApiEndpoint(`/IncidentRoles/GetRoles/${seg(callId)}`).get(); + return response.data; +}; + +/** Caller's effective capabilities for the incident (drives the app's view gating). */ +export const getMyCapabilities = async (callId: string) => { + const response = await createApiEndpoint(`/IncidentRoles/GetMyCapabilities/${seg(callId)}`).get(); + return response.data; +}; diff --git a/src/api/incidentCommand/incidentVoice.ts b/src/api/incidentCommand/incidentVoice.ts new file mode 100644 index 0000000..d91244b --- /dev/null +++ b/src/api/incidentCommand/incidentVoice.ts @@ -0,0 +1,20 @@ +import { createApiEndpoint } from '@/api/common/client'; +import { type IncidentCommandActionResult } from '@/models/v4/incidentCommand/incidentCommand'; +import { type CreateIncidentChannelInput, type IncidentVoiceChannelResult, type IncidentVoiceChannelsResult } from '@/models/v4/incidentCommand/incidentVoiceChannel'; + +const seg = (value: string | number) => encodeURIComponent(String(value)); + +export const createIncidentChannel = async (input: CreateIncidentChannelInput) => { + const response = await createApiEndpoint('/IncidentVoice/CreateIncidentChannel').post({ ...input }); + return response.data; +}; + +export const getChannelsForCall = async (callId: string) => { + const response = await createApiEndpoint(`/IncidentVoice/GetChannelsForCall/${seg(callId)}`).get(); + return response.data; +}; + +export const closeIncidentChannels = async (callId: string) => { + const response = await createApiEndpoint(`/IncidentVoice/CloseIncidentChannels/${seg(callId)}`).post({}); + return response.data; +}; diff --git a/src/api/incidentCommand/sync.ts b/src/api/incidentCommand/sync.ts new file mode 100644 index 0000000..c4ea846 --- /dev/null +++ b/src/api/incidentCommand/sync.ts @@ -0,0 +1,11 @@ +import { createApiEndpoint } from '@/api/common/client'; +import { type SyncBundleResult } from '@/models/v4/incidentCommand/incidentCommandBundle'; + +/** + * Shift-start aggregate: render-ready board (incl. computed PAR) per active incident + ad-hoc + * resources + a delta-sync cursor. Used to list all active incident commands. + */ +export const getSyncBundle = async (includeAccountability = true) => { + const response = await createApiEndpoint('/Sync/Bundle').get({ includeAccountability }); + return response.data; +}; diff --git a/src/api/mapping/mapping.ts b/src/api/mapping/mapping.ts index f0b745e..7d2630f 100644 --- a/src/api/mapping/mapping.ts +++ b/src/api/mapping/mapping.ts @@ -1,3 +1,6 @@ +import { type FeatureCollection } from 'geojson'; + +import { type GetAllActiveLayersResult } from '@/models/v4/mapping/getAllActiveLayersResult'; import { type GetMapDataAndMarkersResult } from '@/models/v4/mapping/getMapDataAndMarkersResult'; import { type GetMapLayersResult } from '@/models/v4/mapping/getMapLayersResult'; import { type PoiResult } from '@/models/v4/mapping/poiResult'; @@ -53,3 +56,31 @@ export const getPoi = async (poiId: number, signal?: AbortSignal) => { }); return response.data; }; + +// --- Custom / indoor map layers (RE1-T105) --------------------------------- + +const getAllActiveLayersApi = createApiEndpoint('/Mapping/GetAllActiveLayers'); + +/** Layers (legacy + custom-map) that are on-by-default for the department. */ +export const getAllActiveLayers = async (signal?: AbortSignal) => { + const response = await getAllActiveLayersApi.get(undefined, signal); + return response.data; +}; + +/** Raw GeoJSON FeatureCollection for a legacy MapLayer (application/geo+json). */ +export const getMapLayerGeoJSON = async (layerId: string, signal?: AbortSignal) => { + const response = await api.get(`/Mapping/GetMapLayerGeoJSON/${encodeURIComponent(layerId)}`, { signal }); + return response.data; +}; + +/** Raw GeoJSON FeatureCollection for a custom-map layer's regions. */ +export const getCustomMapRegionsGeoJSON = async (layerId: string, signal?: AbortSignal) => { + const response = await api.get(`/Mapping/GetCustomMapRegionsGeoJSON/${encodeURIComponent(layerId)}`, { signal }); + return response.data; +}; + +/** Raw GeoJSON FeatureCollection for an indoor-map floor's zones. */ +export const getIndoorMapZonesGeoJSON = async (floorId: string, signal?: AbortSignal) => { + const response = await api.get(`/Mapping/GetIndoorMapZonesGeoJSON/${encodeURIComponent(floorId)}`, { signal }); + return response.data; +}; diff --git a/src/api/weatherAlerts/weatherAlerts.ts b/src/api/weatherAlerts/weatherAlerts.ts index 5fb8306..17b4f84 100644 --- a/src/api/weatherAlerts/weatherAlerts.ts +++ b/src/api/weatherAlerts/weatherAlerts.ts @@ -1,6 +1,9 @@ +import { type BaseV4Request } from '@/models/v4/baseV4Request'; import { type ActiveWeatherAlertsResult } from '@/models/v4/weatherAlerts/activeWeatherAlertsResult'; import { type WeatherAlertResult } from '@/models/v4/weatherAlerts/weatherAlertResult'; +import { type WeatherAlertSettingsData } from '@/models/v4/weatherAlerts/weatherAlertSettingsData'; import { type WeatherAlertSettingsResult } from '@/models/v4/weatherAlerts/weatherAlertSettingsResult'; +import { type WeatherAlertSourcesResult } from '@/models/v4/weatherAlerts/weatherAlertSourcesResult'; import { type WeatherAlertZonesResult } from '@/models/v4/weatherAlerts/weatherAlertZonesResult'; import { createApiEndpoint } from '../common/client'; @@ -42,3 +45,61 @@ export const getWeatherAlertZones = async (signal?: AbortSignal) => { const response = await zonesApi.get(undefined, signal); return response.data; }; + +// --- Configuration / management -------------------------------------------- + +const saveSettingsApi = createApiEndpoint('/WeatherAlerts/SaveSettings'); +const sourcesApi = createApiEndpoint('/WeatherAlerts/GetSources'); +const saveSourceApi = createApiEndpoint('/WeatherAlerts/SaveSource'); +const saveZoneApi = createApiEndpoint('/WeatherAlerts/SaveZone'); + +export interface SaveWeatherAlertSourceInput { + WeatherAlertSourceId?: string; + Name: string; + SourceType: number; + AreaFilter: string; + ApiKey: string; + CustomEndpoint: string; + PollIntervalMinutes: number; + Active: boolean; +} + +export interface SaveWeatherAlertZoneInput { + WeatherAlertZoneId?: string; + Name: string; + ZoneCode: string; + CenterGeoLocation: string; + RadiusMiles: number; + IsActive: boolean; + IsPrimary: boolean; +} + +export const saveWeatherAlertSettings = async (input: WeatherAlertSettingsData) => { + const response = await saveSettingsApi.post({ ...input }); + return response.data; +}; + +export const getWeatherAlertSources = async (signal?: AbortSignal) => { + const response = await sourcesApi.get(undefined, signal); + return response.data; +}; + +export const saveWeatherAlertSource = async (input: SaveWeatherAlertSourceInput) => { + const response = await saveSourceApi.post({ ...input }); + return response.data; +}; + +export const deleteWeatherAlertSource = async (sourceId: string) => { + const response = await createApiEndpoint(`/WeatherAlerts/DeleteSource/${encodeURIComponent(sourceId)}`).delete(); + return response.data; +}; + +export const saveWeatherAlertZone = async (input: SaveWeatherAlertZoneInput) => { + const response = await saveZoneApi.post({ ...input }); + return response.data; +}; + +export const deleteWeatherAlertZone = async (zoneId: string) => { + const response = await createApiEndpoint(`/WeatherAlerts/DeleteZone/${encodeURIComponent(zoneId)}`).delete(); + return response.data; +}; diff --git a/src/app/(app)/home.tsx b/src/app/(app)/home.tsx index 21a8f0d..7fcd842 100644 --- a/src/app/(app)/home.tsx +++ b/src/app/(app)/home.tsx @@ -10,7 +10,20 @@ import { getCallExtraData } from '@/api/calls/calls'; import { getMapDataAndMarkers } from '@/api/mapping/mapping'; import { AudioStreamBottomSheet } from '@/components/audio-stream/audio-stream-bottom-sheet'; import { CloseCallBottomSheet } from '@/components/calls/close-call-bottom-sheet'; -import { ActiveCallFilterBanner, ActiveCallsPanel, ActivityLogPanel, AddNoteBottomSheet, MapWidget, NotesPanel, PersonnelPanel, PTTInterface, StatsHeader, UnitsPanel } from '@/components/dispatch-console'; +import { + ActiveCallFilterBanner, + ActiveCallsPanel, + ActivityLogPanel, + AddNoteBottomSheet, + DashboardViewToggles, + MapWidget, + NotesPanel, + PersonnelPanel, + PTTInterface, + ResourcesPanel, + StatsHeader, + UnitsPanel, +} from '@/components/dispatch-console'; import { Box } from '@/components/ui/box'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { HStack } from '@/components/ui/hstack'; @@ -23,6 +36,7 @@ import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInf import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; import useAuthStore from '@/stores/auth/store'; import { useCallsStore } from '@/stores/calls/store'; +import { useDashboardViewStore } from '@/stores/dispatch/dashboard-view-store'; import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store'; import { useHomeStore } from '@/stores/home/home-store'; import { useNotesStore } from '@/stores/notes/store'; @@ -44,6 +58,7 @@ export default function DispatchConsole() { const { calls, callPriorities, isLoading: callsLoading, fetchCalls, fetchCallPriorities } = useCallsStore(); const { units, isLoading: unitsLoading, fetchUnits } = useUnitsStore(); const { personnel, isLoading: personnelLoading, fetchPersonnel } = usePersonnelStore(); + const singleList = useDashboardViewStore((s) => s.singleList); const { notes, isLoading: notesLoading, fetchNotes } = useNotesStore(); const { lastPersonnelUpdateTimestamp, lastUnitsUpdateTimestamp, lastCallsUpdateTimestamp, lastEventType } = useSignalRStore(); const { userId } = useAuthStore(); @@ -470,6 +485,9 @@ export default function DispatchConsole() { selectedCallId={selectedCallId ?? undefined} callDispatches={selectedCallExtraData?.Dispatches} onSetUnitStatusForCall={handleSetUnitStatusForCall} + selectedPersonnelId={selectedPersonnelId ?? undefined} + onSelectPersonnel={handleSelectPersonnel} + onSetPersonnelStatusForCall={handleSetPersonnelStatusForCall} /> @@ -544,6 +562,9 @@ export default function DispatchConsole() { selectedCallId={selectedCallId ?? undefined} callDispatches={selectedCallExtraData?.Dispatches} onSetUnitStatusForCall={handleSetUnitStatusForCall} + selectedPersonnelId={selectedPersonnelId ?? undefined} + onSelectPersonnel={handleSelectPersonnel} + onSetPersonnelStatusForCall={handleSetPersonnelStatusForCall} /> - - - - - - - - + {singleList ? ( + + ) : ( + + + + + + + + + )} @@ -698,7 +726,10 @@ export default function DispatchConsole() { {isCallFilterActive && selectedCall && } {/* Main Content */} - {renderLayout()} + + + {renderLayout()} + {/* Audio Stream Bottom Sheet */} diff --git a/src/app/(app)/home.web.tsx b/src/app/(app)/home.web.tsx index ca8ae2e..2ee2303 100644 --- a/src/app/(app)/home.web.tsx +++ b/src/app/(app)/home.web.tsx @@ -10,7 +10,20 @@ import { getCallExtraData } from '@/api/calls/calls'; import { getMapDataAndMarkers } from '@/api/mapping/mapping'; import { AudioStreamBottomSheet } from '@/components/audio-stream/audio-stream-bottom-sheet'; import { CloseCallBottomSheet } from '@/components/calls/close-call-bottom-sheet'; -import { ActiveCallFilterBanner, ActiveCallsPanel, ActivityLogPanel, AddNoteBottomSheet, MapWidget, NotesPanel, PersonnelPanel, PTTInterface, StatsHeader, UnitsPanel } from '@/components/dispatch-console'; +import { + ActiveCallFilterBanner, + ActiveCallsPanel, + ActivityLogPanel, + AddNoteBottomSheet, + DashboardViewToggles, + MapWidget, + NotesPanel, + PersonnelPanel, + PTTInterface, + ResourcesPanel, + StatsHeader, + UnitsPanel, +} from '@/components/dispatch-console'; import { Box } from '@/components/ui/box'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { HStack } from '@/components/ui/hstack'; @@ -22,6 +35,7 @@ import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInf import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; import useAuthStore from '@/stores/auth/store'; import { useCallsStore } from '@/stores/calls/store'; +import { useDashboardViewStore } from '@/stores/dispatch/dashboard-view-store'; import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store'; import { useHomeStore } from '@/stores/home/home-store'; import { useNotesStore } from '@/stores/notes/store'; @@ -43,6 +57,7 @@ export default function DispatchConsoleWeb() { const { calls, callPriorities, isLoading: callsLoading, fetchCalls, fetchCallPriorities } = useCallsStore(); const { units, isLoading: unitsLoading, fetchUnits } = useUnitsStore(); const { personnel, isLoading: personnelLoading, fetchPersonnel } = usePersonnelStore(); + const singleList = useDashboardViewStore((s) => s.singleList); const { notes, isLoading: notesLoading, fetchNotes } = useNotesStore(); // SignalR store - subscribe to specific event timestamps @@ -526,6 +541,9 @@ export default function DispatchConsoleWeb() { selectedCallId={selectedCallId ?? undefined} callDispatches={selectedCallExtraData?.Dispatches} onSetUnitStatusForCall={handleSetUnitStatusForCall} + selectedPersonnelId={selectedPersonnelId ?? undefined} + onSelectPersonnel={handleSelectPersonnel} + onSetPersonnelStatusForCall={handleSetPersonnelStatusForCall} /> @@ -600,6 +618,9 @@ export default function DispatchConsoleWeb() { selectedCallId={selectedCallId ?? undefined} callDispatches={selectedCallExtraData?.Dispatches} onSetUnitStatusForCall={handleSetUnitStatusForCall} + selectedPersonnelId={selectedPersonnelId ?? undefined} + onSelectPersonnel={handleSelectPersonnel} + onSetPersonnelStatusForCall={handleSetPersonnelStatusForCall} /> - - - - - - - - + {singleList ? ( + + ) : ( + + + + + + + + + )} @@ -751,7 +779,10 @@ export default function DispatchConsoleWeb() { {isCallFilterActive && selectedCall && } {/* Main Content */} - {renderLayout()} + + + {renderLayout()} + {/* Audio Stream Bottom Sheet */} diff --git a/src/app/(app)/incident-command.tsx b/src/app/(app)/incident-command.tsx new file mode 100644 index 0000000..0d73942 --- /dev/null +++ b/src/app/(app)/incident-command.tsx @@ -0,0 +1,109 @@ +import { type Href, Stack, useRouter } from 'expo-router'; +import React, { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FlatList, Pressable, RefreshControl } from 'react-native'; + +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { FocusAwareStatusBar } from '@/components/ui'; +import { Box } from '@/components/ui/box'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { type IncidentCommandBoard } from '@/models/v4/incidentCommand/incidentCommandBoard'; +import { ParStatus, TacticalObjectiveStatus } from '@/models/v4/incidentCommand/incidentCommandEnums'; +import { useCallsStore } from '@/stores/calls/store'; +import { useIncidentCommandStore } from '@/stores/incident-command/store'; +import { usePersonnelStore } from '@/stores/personnel/store'; + +/** Department-wide list of all active incident commands (calls with an established IC). */ +export default function IncidentCommandList() { + const { t } = useTranslation(); + const router = useRouter(); + const activeBoards = useIncidentCommandStore((s) => s.activeBoards); + const isLoadingActive = useIncidentCommandStore((s) => s.isLoadingActive); + const calls = useCallsStore((s) => s.calls); + const personnel = usePersonnelStore((s) => s.personnel); + + const load = useCallback(() => { + useIncidentCommandStore.getState().fetchActiveCommands(); + }, []); + + useEffect(() => { + load(); + // Load supporting data so the list can show call names/numbers and commander names. + useCallsStore.getState().fetchCalls(); + usePersonnelStore.getState().fetchPersonnel(); + }, [load]); + + const nameFor = (userId: string): string => { + const person = personnel.find((p) => p.UserId === userId); + return person ? `${person.FirstName} ${person.LastName}`.trim() : userId || t('incident_command.unassigned'); + }; + + const renderItem = ({ item }: { item: IncidentCommandBoard }) => { + const command = item.Command; + const call = calls.find((c) => c.CallId === String(command.CallId)); + const critical = (item.Accountability ?? []).filter((p) => p.Status === ParStatus.Critical).length; + const activeLanes = (item.Nodes ?? []).filter((n) => !n.DeletedOn).length; + const roles = (item.Roles ?? []).filter((r) => !r.RemovedOn).length; + const openObjectives = (item.Objectives ?? []).filter((o) => o.Status !== TacticalObjectiveStatus.Complete).length; + + return ( + router.push(`/call/${command.CallId}/command` as Href)} className="mb-2 rounded-lg bg-background-50 p-3"> + + + + {call ? `${call.Name} (#${call.Number})` : `${t('incident_command.call')} ${command.CallId}`} + + + {t('incident_command.commander')}: {nameFor(command.CurrentCommanderUserId)} + + + {critical > 0 ? ( + + + {critical} {t('incident_command.critical')} + + + ) : null} + + + + {activeLanes} {t('incident_command.structure')} + + + {roles} {t('incident_command.roles')} + + + {openObjectives} {t('incident_command.objectives')} + + + + ); + }; + + return ( + <> + + + + {isLoadingActive && activeBoards.length === 0 ? ( + + ) : activeBoards.length === 0 ? ( + + + + ) : ( + item.Command.IncidentCommandId} + renderItem={renderItem} + contentContainerStyle={{ padding: 12 }} + refreshControl={} + /> + )} + + + ); +} diff --git a/src/app/(app)/map.tsx b/src/app/(app)/map.tsx index d33d51d..6aa3a2c 100644 --- a/src/app/(app)/map.tsx +++ b/src/app/(app)/map.tsx @@ -12,6 +12,7 @@ import { getMapDataAndMarkers } from '@/api/mapping/mapping'; import MapPins from '@/components/maps/map-pins'; import PinDetailModal from '@/components/maps/pin-detail-modal'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { useActiveMapLayers } from '@/hooks/use-active-map-layers'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAppLifecycle } from '@/hooks/use-app-lifecycle'; import { MapLayerType, useMapLayers } from '@/hooks/use-map-layers'; @@ -55,6 +56,9 @@ export default function Map() { // Map layers hook const { layers, visibleLayers, isLoading: isLayersLoading, fetchLayers, toggleLayer, showAllLayers, hideAllLayers, getVisibleLayerData } = useMapLayers({ initialLayerType: MapLayerType.ALL, autoFetch: true }); + // Custom-map region layers (RE1-T105) rendered on top of the legacy vector layers. + const { activeLayers } = useActiveMapLayers(); + const _mapOptions = Object.keys(Mapbox.StyleURL) .map((key) => { return { @@ -477,6 +481,21 @@ export default function Map() { }); }; + // Render on-by-default custom-map region layers (GeoJSON) fetched via GetAllActiveLayers. + const renderActiveCustomLayers = () => { + return activeLayers.map((layer) => { + if (!layer.data?.features || layer.data.features.length === 0) { + return null; + } + return ( + + + + + ); + }); + }; + // Render layers panel modal const renderLayersPanel = () => { const isDark = colorScheme === 'dark'; @@ -563,6 +582,7 @@ export default function Map() { {/* Render custom layers */} {renderMapLayers()} + {renderActiveCustomLayers()} {location.latitude && location.longitude ? ( diff --git a/src/app/(app)/map.web.tsx b/src/app/(app)/map.web.tsx index 49947cb..7ba754f 100644 --- a/src/app/(app)/map.web.tsx +++ b/src/app/(app)/map.web.tsx @@ -8,6 +8,7 @@ import { Modal, Pressable, ScrollView, StyleSheet, Switch, Text, TouchableOpacit import { getMapDataAndMarkers } from '@/api/mapping/mapping'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { useActiveMapLayers } from '@/hooks/use-active-map-layers'; import { useAnalytics } from '@/hooks/use-analytics'; import { MapLayerType, useMapLayers } from '@/hooks/use-map-layers'; import { Env } from '@/lib/env'; @@ -32,6 +33,8 @@ export default function MapWeb() { const markersRef = useRef([]); const layerIdsRef = useRef([]); const sourceIdsRef = useRef([]); + const activeSourceIdsRef = useRef([]); + const activeLayerIdsRef = useRef([]); const [isMapReady, setIsMapReady] = useState(false); const [mapPins, setMapPins] = useState([]); const [poiLayers, setPoiLayers] = useState([]); @@ -46,6 +49,9 @@ export default function MapWeb() { // Map layers hook const { layers, visibleLayers, isLoading: isLayersLoading, fetchLayers, toggleLayer, showAllLayers, hideAllLayers, getVisibleLayerData } = useMapLayers({ initialLayerType: MapLayerType.ALL, autoFetch: true }); + // Custom-map region layers (RE1-T105) rendered on top of the legacy vector layers. + const { activeLayers } = useActiveMapLayers(); + const syncPoiLayers = useCallback((nextPoiLayers: PoiLayerData[]) => { setPoiLayers(nextPoiLayers); setVisiblePoiLayerIds(createDefaultVisiblePoiLayerIds(nextPoiLayers)); @@ -147,9 +153,23 @@ export default function MapWeb() { // Update map style when theme changes useEffect(() => { - if (map.current && isMapReady) { - map.current.setStyle(getMapStyle()); - } + const instance = map.current; + if (!instance || !isMapReady) return; + + instance.setStyle(getMapStyle()); + + // setStyle replaces the whole style and drops every runtime-added source/layer. + // Re-add the runtime layers once the new style has finished loading. Custom + // (base) layers go first so the active region layers stay rendered on top. + const handleStyleLoad = () => { + addCustomMapLayersRef.current(instance); + addActiveMapLayersRef.current(instance); + }; + instance.once('style.load', handleStyleLoad); + + return () => { + instance.off('style.load', handleStyleLoad); + }; }, [colorScheme, getMapStyle, isMapReady]); // Handle navigation focus - refresh data when navigating back to map @@ -241,100 +261,162 @@ export default function MapWeb() { }); }, [visibleMapPins, isMapReady, colorScheme]); - // Update layers when visibility changes - useEffect(() => { - if (!map.current || !isMapReady) return; - - // Remove existing custom layers - layerIdsRef.current.forEach((layerId) => { - if (map.current?.getLayer(layerId)) { - map.current.removeLayer(layerId); - } - }); - sourceIdsRef.current.forEach((sourceId) => { - if (map.current?.getSource(sourceId)) { - map.current.removeSource(sourceId); - } - }); - layerIdsRef.current = []; - sourceIdsRef.current = []; + // Update layers when visibility changes. + // Extracted so it can also be re-run after a style reload (mapbox setStyle wipes + // every runtime-added source/layer, so theme changes would otherwise drop these). + const addCustomMapLayers = useCallback( + (instance: mapboxgl.Map) => { + // Remove existing custom layers + layerIdsRef.current.forEach((layerId) => { + if (instance.getLayer(layerId)) { + instance.removeLayer(layerId); + } + }); + sourceIdsRef.current.forEach((sourceId) => { + if (instance.getSource(sourceId)) { + instance.removeSource(sourceId); + } + }); + layerIdsRef.current = []; + sourceIdsRef.current = []; - // Add visible layers - const visibleLayerData = getVisibleLayerData(); + // Add visible layers + const visibleLayerData = getVisibleLayerData(); - visibleLayerData.forEach((layer) => { - if (!layer.Data?.Features || !Array.isArray(layer.Data.Features) || layer.Data.Features.length === 0) return; + visibleLayerData.forEach((layer) => { + if (!layer.Data?.Features || !Array.isArray(layer.Data.Features) || layer.Data.Features.length === 0) return; - const sourceId = `custom-layer-source-${layer.Id}`; - const layerId = `custom-layer-${layer.Id}`; + const sourceId = `custom-layer-source-${layer.Id}`; + const layerId = `custom-layer-${layer.Id}`; - // Build a proper GeoJSON FeatureCollection from the layer data - const featureCollection: FeatureCollection = { - type: 'FeatureCollection', - features: layer.Data.Features.flatMap((fc) => fc.features || []) as Feature[], - }; + // Build a proper GeoJSON FeatureCollection from the layer data + const featureCollection: FeatureCollection = { + type: 'FeatureCollection', + features: layer.Data.Features.flatMap((fc) => fc.features || []) as Feature[], + }; - if (featureCollection.features.length === 0) return; + if (featureCollection.features.length === 0) return; - try { - // Add source - map.current!.addSource(sourceId, { - type: 'geojson', - data: featureCollection, - }); - sourceIdsRef.current.push(sourceId); - - // Determine layer type and add appropriate layer - const type = layer.Data.Type?.toLowerCase() || 'polygon'; - const color = layer.Color || '#3b82f6'; - - if (type === 'linestring' || type === 'multilinestring') { - map.current!.addLayer({ - id: layerId, - type: 'line', - source: sourceId, - paint: { - 'line-color': color, - 'line-width': 3, - 'line-opacity': 0.8, - }, + try { + // Add source + instance.addSource(sourceId, { + type: 'geojson', + data: featureCollection, }); - } else if (type === 'point' || type === 'multipoint') { - map.current!.addLayer({ - id: layerId, - type: 'circle', - source: sourceId, - paint: { - 'circle-color': color, - 'circle-radius': 8, - 'circle-opacity': 0.8, - 'circle-stroke-color': '#ffffff', - 'circle-stroke-width': 2, - }, - }); - } else { - // Default to fill layer for polygons - map.current!.addLayer({ - id: layerId, - type: 'fill', - source: sourceId, - paint: { - 'fill-color': color, - 'fill-opacity': 0.3, - 'fill-outline-color': color, - }, + sourceIdsRef.current.push(sourceId); + + // Determine layer type and add appropriate layer + const type = layer.Data.Type?.toLowerCase() || 'polygon'; + const color = layer.Color || '#3b82f6'; + + if (type === 'linestring' || type === 'multilinestring') { + instance.addLayer({ + id: layerId, + type: 'line', + source: sourceId, + paint: { + 'line-color': color, + 'line-width': 3, + 'line-opacity': 0.8, + }, + }); + } else if (type === 'point' || type === 'multipoint') { + instance.addLayer({ + id: layerId, + type: 'circle', + source: sourceId, + paint: { + 'circle-color': color, + 'circle-radius': 8, + 'circle-opacity': 0.8, + 'circle-stroke-color': '#ffffff', + 'circle-stroke-width': 2, + }, + }); + } else { + // Default to fill layer for polygons + instance.addLayer({ + id: layerId, + type: 'fill', + source: sourceId, + paint: { + 'fill-color': color, + 'fill-opacity': 0.3, + 'fill-outline-color': color, + }, + }); + } + + layerIdsRef.current.push(layerId); + } catch (error) { + logger.error({ + message: 'Failed to add map layer', + context: { error, layerId: layer.Id }, }); } + }); + }, + [getVisibleLayerData] + ); - layerIdsRef.current.push(layerId); - } catch (error) { - logger.error({ - message: 'Failed to add map layer', - context: { error, layerId: layer.Id }, - }); - } - }); - }, [visibleLayers, layers, isMapReady, getVisibleLayerData]); + // Keep a ref to the latest builder so the theme-change effect can re-add layers + // after a style reload without re-running setStyle on every data change. + const addCustomMapLayersRef = useRef(addCustomMapLayers); + useEffect(() => { + addCustomMapLayersRef.current = addCustomMapLayers; + }, [addCustomMapLayers]); + + useEffect(() => { + const instance = map.current; + if (!instance || !isMapReady) return; + addCustomMapLayers(instance); + }, [addCustomMapLayers, isMapReady]); + + // Render on-by-default custom-map region layers (GeoJSON) from GetAllActiveLayers. + // Extracted so it can also be re-run after a style reload (mapbox setStyle wipes + // every runtime-added source/layer, so theme changes would otherwise drop these). + const addActiveMapLayers = useCallback( + (instance: mapboxgl.Map) => { + activeLayerIdsRef.current.forEach((id) => { + if (instance.getLayer(id)) instance.removeLayer(id); + }); + activeSourceIdsRef.current.forEach((id) => { + if (instance.getSource(id)) instance.removeSource(id); + }); + activeLayerIdsRef.current = []; + activeSourceIdsRef.current = []; + + activeLayers.forEach((layer) => { + if (!layer.data?.features || layer.data.features.length === 0) return; + const sourceId = `active-layer-source-${layer.id}`; + const fillId = `active-layer-fill-${layer.id}`; + const lineId = `active-layer-line-${layer.id}`; + try { + instance.addSource(sourceId, { type: 'geojson', data: layer.data }); + instance.addLayer({ id: fillId, type: 'fill', source: sourceId, paint: { 'fill-color': layer.color, 'fill-opacity': 0.25, 'fill-outline-color': layer.color } }); + instance.addLayer({ id: lineId, type: 'line', source: sourceId, paint: { 'line-color': layer.color, 'line-width': 2, 'line-opacity': 0.8 } }); + activeSourceIdsRef.current.push(sourceId); + activeLayerIdsRef.current.push(fillId, lineId); + } catch (error) { + logger.error({ message: 'Failed to add active map layer', context: { error, layerId: layer.id } }); + } + }); + }, + [activeLayers] + ); + + // Keep a ref to the latest builder so the theme-change effect can re-add layers + // after a style reload without re-running setStyle on every activeLayers change. + const addActiveMapLayersRef = useRef(addActiveMapLayers); + useEffect(() => { + addActiveMapLayersRef.current = addActiveMapLayers; + }, [addActiveMapLayers]); + + useEffect(() => { + const instance = map.current; + if (!instance || !isMapReady) return; + addActiveMapLayers(instance); + }, [addActiveMapLayers, isMapReady]); // Track when map view is rendered useEffect(() => { diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index cc4e6a3..fd3ede3 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -8,6 +8,7 @@ import { Item } from '@/components/settings/item'; import { KeepAliveItem } from '@/components/settings/keep-alive-item'; import { LanguageItem } from '@/components/settings/language-item'; import { LoginInfoBottomSheet } from '@/components/settings/login-info-bottom-sheet'; +import { ModernNotificationSoundsItem } from '@/components/settings/modern-notification-sounds-item'; import { ServerUrlBottomSheet } from '@/components/settings/server-url-bottom-sheet'; import { ThemeItem } from '@/components/settings/theme-item'; import { ToggleItem } from '@/components/settings/toggle-item'; @@ -97,6 +98,7 @@ export default function Settings() { + diff --git a/src/app/(app)/weather-alerts/index.tsx b/src/app/(app)/weather-alerts/index.tsx index eec65eb..3679d0e 100644 --- a/src/app/(app)/weather-alerts/index.tsx +++ b/src/app/(app)/weather-alerts/index.tsx @@ -1,5 +1,6 @@ import { useFocusEffect } from '@react-navigation/native'; import { type Href, router } from 'expo-router'; +import { SettingsIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -106,7 +107,12 @@ export default function WeatherAlertsListScreen() { return ( {/* Header */} - {t('weatherAlerts.title')} + + {t('weatherAlerts.title')} + router.push('/weather-alerts/settings' as Href)} style={styles.settingsButton} testID="weather-alert-settings-button"> + + + {/* Filter pills */} @@ -146,6 +152,16 @@ const styles = StyleSheet.create({ containerLight: { backgroundColor: '#f3f4f6' }, containerDark: { backgroundColor: '#030712' }, + headerRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingRight: 16, + }, + settingsButton: { + padding: 8, + }, + title: { fontSize: 18, fontWeight: '700', diff --git a/src/app/(app)/weather-alerts/settings.tsx b/src/app/(app)/weather-alerts/settings.tsx new file mode 100644 index 0000000..24088e7 --- /dev/null +++ b/src/app/(app)/weather-alerts/settings.tsx @@ -0,0 +1,292 @@ +import { Stack } from 'expo-router'; +import { PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert, Platform, ScrollView } from 'react-native'; + +import { FocusAwareStatusBar } from '@/components/ui'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Select, SelectBackdrop, SelectContent, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select'; +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 { WeatherAlertSourceSheet } from '@/components/weatherAlerts/weather-alert-source-sheet'; +import { WeatherAlertZoneSheet } from '@/components/weatherAlerts/weather-alert-zone-sheet'; +import { WeatherAlertSeverity, WeatherAlertSourceType } from '@/models/v4/weatherAlerts/weatherAlertEnums'; +import { type WeatherAlertSourceResultData } from '@/models/v4/weatherAlerts/weatherAlertSourceResultData'; +import { type WeatherAlertZoneResultData } from '@/models/v4/weatherAlerts/weatherAlertZoneResultData'; +import { useToastStore } from '@/stores/toast/store'; +import { useWeatherAlertsStore } from '@/stores/weatherAlerts/store'; + +const SEVERITY_OPTIONS = [ + { value: WeatherAlertSeverity.Extreme, label: 'Extreme' }, + { value: WeatherAlertSeverity.Severe, label: 'Severe' }, + { value: WeatherAlertSeverity.Moderate, label: 'Moderate' }, + { value: WeatherAlertSeverity.Minor, label: 'Minor' }, + { value: WeatherAlertSeverity.Unknown, label: 'Unknown' }, +]; + +const sourceTypeLabel = (type: number): string => + ({ + [WeatherAlertSourceType.NationalWeatherService]: 'National Weather Service', + [WeatherAlertSourceType.EnvironmentCanada]: 'Environment Canada', + [WeatherAlertSourceType.MeteoAlarm]: 'MeteoAlarm', + })[type] ?? 'Source'; + +const confirmDelete = (message: string, onConfirm: () => void) => { + if (Platform.OS === 'web') { + // eslint-disable-next-line no-alert + if (typeof window !== 'undefined' && window.confirm(message)) onConfirm(); + return; + } + Alert.alert('', message, [ + { text: 'Cancel', style: 'cancel' }, + { text: 'OK', style: 'destructive', onPress: onConfirm }, + ]); +}; + +/** Weather-alert configuration: department settings + alert zones + alert sources. */ +export default function WeatherAlertSettingsScreen() { + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + const showToast = useToastStore((s) => s.showToast); + const settings = useWeatherAlertsStore((s) => s.settings); + const zones = useWeatherAlertsStore((s) => s.zones); + const sources = useWeatherAlertsStore((s) => s.sources); + const isSavingSettings = useWeatherAlertsStore((s) => s.isSavingSettings); + + const [enabled, setEnabled] = useState(true); + const [minSeverity, setMinSeverity] = useState(String(WeatherAlertSeverity.Severe)); + const [autoSeverity, setAutoSeverity] = useState(String(WeatherAlertSeverity.Extreme)); + const [callIntegration, setCallIntegration] = useState(false); + const [excludedEvents, setExcludedEvents] = useState(''); + + const [zoneSheetOpen, setZoneSheetOpen] = useState(false); + const [editingZone, setEditingZone] = useState(null); + const [sourceSheetOpen, setSourceSheetOpen] = useState(false); + const [editingSource, setEditingSource] = useState(null); + + useEffect(() => { + useWeatherAlertsStore.getState().fetchSettings(); + useWeatherAlertsStore.getState().fetchZones(); + useWeatherAlertsStore.getState().fetchSources(); + }, []); + + useEffect(() => { + if (!settings) return; + setEnabled(settings.WeatherAlertsEnabled); + setMinSeverity(String(settings.MinimumSeverity)); + setAutoSeverity(String(settings.AutoMessageSeverity)); + setCallIntegration(settings.CallIntegrationEnabled); + setExcludedEvents(settings.ExcludedEvents ?? ''); + }, [settings]); + + const handleSaveSettings = async () => { + try { + await useWeatherAlertsStore.getState().saveSettings({ + WeatherAlertsEnabled: enabled, + MinimumSeverity: parseInt(minSeverity, 10), + AutoMessageSeverity: parseInt(autoSeverity, 10), + CallIntegrationEnabled: callIntegration, + AutoMessageSchedule: settings?.AutoMessageSchedule ?? [], + ExcludedEvents: excludedEvents.trim(), + }); + showToast('success', t('weatherAlerts.settings.saved')); + } catch { + showToast('error', t('weatherAlerts.settings.save_error')); + } + }; + + const cardClass = `mb-3 rounded-lg p-4 ${colorScheme === 'dark' ? 'bg-neutral-900' : 'bg-neutral-100'}`; + + const renderSeveritySelect = (value: string, onChange: (v: string) => void) => ( + + ); + + return ( + <> + + + + + {/* Settings */} + + + {t('weatherAlerts.settings.general')} + + + {t('weatherAlerts.settings.enabled')} + + + + {t('weatherAlerts.settings.minimum_severity')} + {renderSeveritySelect(minSeverity, setMinSeverity)} + + + {t('weatherAlerts.settings.auto_message_severity')} + {renderSeveritySelect(autoSeverity, setAutoSeverity)} + + + {t('weatherAlerts.settings.call_integration')} + + + + {t('weatherAlerts.settings.excluded_events')} + + + + + + {/* Zones */} + + + {t('weatherAlerts.settings.zones')} + + + {zones.length === 0 ? ( + {t('weatherAlerts.settings.no_zones')} + ) : ( + + {zones.map((zone) => ( + + + + {zone.Name} {zone.IsPrimary ? ({t('weatherAlerts.settings.primary')}) : null} + + + {zone.ZoneCode || '—'} · {zone.RadiusMiles} mi · {zone.IsActive ? t('weatherAlerts.settings.active') : t('weatherAlerts.settings.inactive')} + + + + + + + + ))} + + )} + + + {/* Sources */} + + + {t('weatherAlerts.settings.sources')} + + + {sources.length === 0 ? ( + {t('weatherAlerts.settings.no_sources')} + ) : ( + + {sources.map((source) => ( + + + {source.Name} + + {sourceTypeLabel(source.SourceType)} · {source.Active ? t('weatherAlerts.settings.active') : t('weatherAlerts.settings.inactive')} + + {source.IsFailure && source.ErrorMessage ? {source.ErrorMessage} : null} + + + + + + + ))} + + )} + + + + setZoneSheetOpen(false)} zone={editingZone} /> + setSourceSheetOpen(false)} source={editingSource} /> + + ); +} diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index 66b54dc..43e792b 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -1,15 +1,33 @@ import { type Href, Stack, useLocalSearchParams, useRouter } from 'expo-router'; -import { ClockIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, PaperclipIcon, RouteIcon, ShieldCheckIcon, UserIcon, UsersIcon, VideoIcon } from 'lucide-react-native'; +import { + ClockIcon, + FileTextIcon, + ImageIcon, + InfoIcon, + LoaderIcon, + MapPinIcon, + NavigationIcon, + NetworkIcon, + PaperclipIcon, + RouteIcon, + ShieldCheckIcon, + UserIcon, + UserPlusIcon, + UsersIcon, + VideoIcon, + Volume2Icon, +} from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; +import { Alert, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; import WebView from 'react-native-webview'; import { VideoFeedsTab } from '@/components/callVideoFeeds/video-feeds-tab'; import { CheckInTab } from '@/components/checkIn/check-in-tab'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; +import { IncidentCommandTab } from '@/components/incident-command/incident-command-tab'; // Import a static map component instead of react-native-maps import StaticMap from '@/components/maps/static-map'; import { FocusAwareStatusBar, SafeAreaView } from '@/components/ui'; @@ -21,22 +39,29 @@ import { SharedTabs, type TabItem } from '@/components/ui/shared-tabs'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; +import { buildAddResourcesUpdateRequest, EMPTY_DISPATCH_SELECTION } from '@/lib/dispatch-helpers'; import { logger } from '@/lib/logging'; import { openMapsWithDirections } from '@/lib/navigation'; -import { formatDateForDisplay, parseDateISOString } from '@/lib/utils'; +import { formatDateForDisplay, isCallActive, parseDateISOString } from '@/lib/utils'; +import { CheckInTimerStatus } from '@/models/v4/checkIn/checkInEnums'; import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; import { useCallDetailStore } from '@/stores/calls/detail-store'; +import { useCallsStore } from '@/stores/calls/store'; import { useCheckInStore } from '@/stores/checkIn/store'; +import { type DispatchSelection } from '@/stores/dispatch/store'; import { useSecurityStore } from '@/stores/security/store'; import { useStatusBottomSheetStore } from '@/stores/status/store'; import { useToastStore } from '@/stores/toast/store'; +import CallAudioModal from '../../components/calls/call-audio-modal'; import { useCallDetailMenu } from '../../components/calls/call-detail-menu'; import CallFilesModal from '../../components/calls/call-files-modal'; import CallImagesModal from '../../components/calls/call-images-modal'; import CallNotesModal from '../../components/calls/call-notes-modal'; import { CloseCallBottomSheet } from '../../components/calls/close-call-bottom-sheet'; +import { DispatchSelectionModal } from '../../components/calls/dispatch-selection-modal'; +import { RescheduleCallSheet } from '../../components/calls/reschedule-call-sheet'; import { StatusBottomSheet } from '../../components/status/status-bottom-sheet'; export default function CallDetail() { @@ -62,8 +87,12 @@ export default function CallDetail() { const [isNotesModalOpen, setIsNotesModalOpen] = useState(false); const [isImagesModalOpen, setIsImagesModalOpen] = useState(false); const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); + const [isAudioModalOpen, setIsAudioModalOpen] = useState(false); + const [isRescheduleModalOpen, setIsRescheduleModalOpen] = useState(false); const [isCloseCallModalOpen, setIsCloseCallModalOpen] = useState(false); const [isSettingActive, setIsSettingActive] = useState(false); + const [mapView, setMapView] = useState<'call' | 'destination'>('call'); + const [isDispatchModalOpen, setIsDispatchModalOpen] = useState(false); const showToast = useToastStore((state) => state.showToast); const { colorScheme } = useColorScheme(); @@ -92,6 +121,10 @@ export default function CallDetail() { setIsFilesModalOpen(true); }; + const openAudioModal = () => { + setIsAudioModalOpen(true); + }; + const handleEditCall = () => { router.push(`/call/${callId}/edit` as Href); }; @@ -100,6 +133,30 @@ export default function CallDetail() { setIsCloseCallModalOpen(true); }; + const handleRescheduleCall = () => { + setIsRescheduleModalOpen(true); + }; + + const handleDeleteCall = () => { + Alert.alert(t('call_detail.delete_call'), t('call_detail.delete_call_confirm'), [ + { text: t('common.cancel'), style: 'cancel' }, + { + text: t('call_detail.delete_call'), + style: 'destructive', + onPress: async () => { + try { + await useCallDetailStore.getState().deleteCall(callId); + showToast('success', t('call_detail.delete_call_success')); + await useCallsStore.getState().fetchCalls(); + router.back(); + } catch { + showToast('error', t('call_detail.delete_call_error')); + } + }, + }, + ]); + }; + const handleSetActive = async () => { if (!call) return; @@ -126,10 +183,30 @@ export default function CallDetail() { } }; + // Dispatch additional resources to this (active) call. The picked resources are unioned with the + // call's existing dispatches so nothing is ever un-dispatched — the server then notifies only the + // newly-added resources (RebroadcastCall stays false). + const handleDispatchAdditional = async (selection: DispatchSelection) => { + if (!call) return; + + try { + await useCallDetailStore.getState().updateCall(buildAddResourcesUpdateRequest(call, callExtraData?.Dispatches, selection, callExtraData?.CallFormData)); + showToast('success', t('call_detail.dispatch_more_success')); + } catch (error) { + logger.error({ message: 'Failed to dispatch additional resources', context: { error, callId: call.CallId } }); + showToast('error', t('call_detail.dispatch_more_error')); + } + }; + + const isScheduledPending = !!(call?.ScheduledOn || call?.ScheduledOnUtc) && !call?.DispatchedOn; + // Initialize the call detail menu hook const { HeaderRightMenu, CallDetailActionSheet } = useCallDetailMenu({ onEditCall: handleEditCall, onCloseCall: handleCloseCall, + onDeleteCall: handleDeleteCall, + onRescheduleCall: isScheduledPending ? handleRescheduleCall : undefined, + onDispatchMore: call && isCallActive(call.State) ? () => setIsDispatchModalOpen(true) : undefined, canUserCreateCalls, }); @@ -445,6 +522,12 @@ export default function CallDetail() { icon: , content: ( + {canUserCreateCalls && isCallActive(call.State) ? ( + + ) : null} {callExtraData?.Dispatches && callExtraData.Dispatches.length > 0 ? ( {callExtraData.Dispatches.map((dispatched, index) => ( @@ -497,6 +580,14 @@ export default function CallDetail() { }, ]; + // Incident Command tab — lets dispatch see and interact with the established incident command. + tabs.push({ + key: 'command', + title: t('incident_command.tab_title'), + icon: , + content: , + }); + // Video feeds tab tabs.push({ key: 'video', @@ -506,9 +597,11 @@ export default function CallDetail() { }); if (call?.CheckInTimersEnabled) { - const overdueCount = timerStatuses.filter((s) => s.Status === 'Overdue').length; - const warningCount = timerStatuses.filter((s) => s.Status === 'Warning').length; - const badgeCount = overdueCount + warningCount; + // Align with the check-in tab's own summary, which treats Red==Overdue, Yellow==Warning, and counts Critical. + const overdueCount = timerStatuses.filter((s) => s.Status === CheckInTimerStatus.Overdue || s.Status === CheckInTimerStatus.Red).length; + const warningCount = timerStatuses.filter((s) => s.Status === CheckInTimerStatus.Warning || s.Status === CheckInTimerStatus.Yellow).length; + const criticalCount = timerStatuses.filter((s) => s.Status === CheckInTimerStatus.Critical).length; + const badgeCount = overdueCount + warningCount + criticalCount; tabs.push({ key: 'checkin', @@ -522,6 +615,13 @@ export default function CallDetail() { return tabs; }; + // Map can toggle between the call (dispatch) location and the call's destination POI location. + const hasDestinationLocation = call.DestinationLatitude != null && call.DestinationLongitude != null; + const showDestinationMap = mapView === 'destination' && hasDestinationLocation; + const mapLatitude = showDestinationMap ? call.DestinationLatitude : coordinates.latitude; + const mapLongitude = showDestinationMap ? call.DestinationLongitude : coordinates.longitude; + const mapAddress = showDestinationMap ? call.DestinationAddress || call.DestinationName : call.Address; + return ( <> - {/* Map */} + {/* Map (toggles between the call address and the destination POI when one exists) */} - {coordinates.latitude && coordinates.longitude ? : null} + {hasDestinationLocation ? ( + + + + + ) : null} + {mapLatitude && mapLongitude ? : null} {/* Action Buttons */} @@ -624,6 +736,17 @@ export default function CallDetail() { ) : null} + + + {call?.AudioCount ? ( + + {call.AudioCount} + + ) : null} + + ) : null} {callExtraData?.Dispatches && callExtraData.Dispatches.length > 0 ? ( callExtraData.Dispatches.map((dispatched, index) => ( @@ -337,9 +410,22 @@ export default function CallDetailWeb() { )} ); + case 'command': + return ( + + + + ); } }; + // Map can toggle between the call (dispatch) location and the call's destination POI location. + const hasDestinationLocation = call?.DestinationLatitude != null && call?.DestinationLongitude != null; + const showDestinationMap = mapView === 'destination' && hasDestinationLocation; + const mapLatitude = showDestinationMap ? (call?.DestinationLatitude ?? null) : coordinates.latitude; + const mapLongitude = showDestinationMap ? (call?.DestinationLongitude ?? null) : coordinates.longitude; + const mapAddress = showDestinationMap ? call?.DestinationAddress || call?.DestinationName : call?.Address; + return ( <> @@ -386,10 +472,32 @@ export default function CallDetailWeb() { {/* Left Column - Map & Actions */} - {/* Map */} - {coordinates.latitude && coordinates.longitude ? ( + {/* Map (toggles between the call address and the destination POI when one exists) */} + {mapLatitude && mapLongitude ? ( - + {hasDestinationLocation ? ( + + setMapView('call')} + > + + + {t('call_detail.map_call')} + + + setMapView('destination')} + > + + + {t('call_detail.map_destination')} + + + + ) : null} + {t('common.route')} @@ -402,6 +510,7 @@ export default function CallDetailWeb() { setIsImagesModalOpen(true)} isDark={isDark} /> setIsFilesModalOpen(true)} isDark={isDark} /> + @@ -446,8 +555,11 @@ export default function CallDetailWeb() { setIsNotesModalOpen(false)} callId={callId} /> setIsImagesModalOpen(false)} callId={callId} /> setIsFilesModalOpen(false)} callId={callId} /> + setIsAudioModalOpen(false)} callId={callId} /> setIsCloseCallModalOpen(false)} callId={callId} /> + setIsRescheduleModalOpen(false)} callId={callId} /> + setIsDispatchModalOpen(false)} onConfirm={handleDispatchAdditional} initialSelection={EMPTY_DISPATCH_SELECTION} /> ); @@ -653,6 +765,42 @@ const styles = StyleSheet.create({ mapCardLight: { backgroundColor: '#ffffff', }, + mapToggleRow: { + flexDirection: 'row', + gap: 6, + padding: 8, + }, + mapToggleButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 4, + paddingVertical: 6, + borderRadius: 6, + }, + mapToggleButtonActive: { + backgroundColor: '#2563eb', + }, + mapToggleButtonLight: { + backgroundColor: '#e5e7eb', + }, + mapToggleButtonDark: { + backgroundColor: '#374151', + }, + mapToggleText: { + fontSize: 12, + fontWeight: '600', + }, + mapToggleTextActive: { + color: '#ffffff', + }, + mapToggleTextLight: { + color: '#4b5563', + }, + mapToggleTextDark: { + color: '#9ca3af', + }, routeOverlay: { position: 'absolute', bottom: 12, diff --git a/src/app/call/[id]/command.tsx b/src/app/call/[id]/command.tsx new file mode 100644 index 0000000..6c6a60f --- /dev/null +++ b/src/app/call/[id]/command.tsx @@ -0,0 +1,26 @@ +import { Stack, useLocalSearchParams } from 'expo-router'; +import { useColorScheme } from 'nativewind'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView } from 'react-native'; + +import { IncidentCommandTab } from '@/components/incident-command/incident-command-tab'; +import { FocusAwareStatusBar } from '@/components/ui'; + +/** Full-screen incident command board for a call (a larger canvas than the call-detail tab). */ +export default function CallCommandBoard() { + const { id } = useLocalSearchParams(); + const callId = Array.isArray(id) ? id[0] : id; + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + + return ( + <> + + + + {callId ? : null} + + + ); +} diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx index 7469dcb..1669c9b 100644 --- a/src/app/call/[id]/edit.tsx +++ b/src/app/call/[id]/edit.tsx @@ -23,6 +23,7 @@ import { Card } from '@/components/ui/card'; import { FormControl, FormControlError, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control'; import { Input, InputField } from '@/components/ui/input'; import { Select, SelectBackdrop, SelectContent, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; import { Text } from '@/components/ui/text'; import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { useToast } from '@/components/ui/toast'; @@ -58,6 +59,7 @@ const formSchema = z.object({ roles: z.array(z.string()), units: z.array(z.string()), }), + notifyCancelledEntities: z.boolean().optional(), }); type FormValues = z.infer; @@ -145,6 +147,7 @@ export default function EditCall() { roles: [], units: [], }, + notifyCancelledEntities: false, }, }); @@ -289,6 +292,7 @@ export default function EditCall() { dispatchRoles: data.dispatchSelection?.roles, dispatchUnits: data.dispatchSelection?.units, dispatchEveryone: data.dispatchSelection?.everyone, + notifyCancelledEntities: data.notifyCancelledEntities, }); if (udfValues.length > 0 && callId) { @@ -761,6 +765,16 @@ export default function EditCall() { + + + + {t('calls.notify_cancelled_entities')} + {t('calls.notify_cancelled_entities_description')} + + } /> + + + + + ); + } + + if (!callAudio || callAudio.length === 0) { + return ( + + + {t('calls.audio.no_audio')} + {t('calls.audio.no_audio_description')} + + ); + } + + return ( + + {callAudio.map(renderAudioItem)} + + ); + }; + + return ( + <> +