From b92c992233c1db0f2579c4da0ce44d06af019634 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 27 Apr 2026 10:08:08 -0700 Subject: [PATCH 1/5] RD-T42 Adding POIs --- package.json | 22 +- src/api/calls/calls.ts | 11 + src/api/dispatch/dispatch.ts | 11 +- src/api/mapping/mapping.ts | 36 ++- src/api/personnel/personnelStatuses.ts | 33 ++- src/app/(app)/map.tsx | 112 +++++--- src/app/(app)/map.web.tsx | 136 +++++++--- src/app/(app)/pois.tsx | 168 ++++++++++++ src/app/(app)/scheduled-calls.tsx | 148 +++++++++++ src/app/call/[id].tsx | 26 ++ src/app/call/[id].web.tsx | 11 + src/app/call/[id]/edit.tsx | 64 +++++ src/app/call/[id]/edit.web.tsx | 69 ++++- src/app/call/new/index.tsx | 63 +++++ src/app/call/new/index.web.tsx | 66 ++++- src/app/poi/[id].tsx | 3 + src/app/poi/[id].web.tsx | 3 + src/components/calls/call-card.tsx | 28 ++ src/components/calls/call-card.web.tsx | 15 ++ src/components/calls/scheduled-call-card.tsx | 246 ++++++++++++++++++ .../personnel-actions-panel.tsx | 116 ++++++--- .../dispatch-console/personnel-panel.tsx | 4 +- .../dispatch-console/unit-actions-panel.tsx | 129 +++++---- .../dispatch-console/units-panel.tsx | 4 +- src/components/maps/map-pins.tsx | 16 +- src/components/maps/pin-detail-modal.tsx | 28 +- src/components/maps/pin-marker.tsx | 43 ++- src/components/maps/unified-map-view.web.tsx | 59 +++-- src/components/pois/poi-card.tsx | 120 +++++++++ src/components/pois/poi-detail-screen.tsx | 243 +++++++++++++++++ .../sidebar/__tests__/side-menu.test.tsx | 18 +- src/components/sidebar/side-menu.tsx | 4 +- src/components/status/status-bottom-sheet.tsx | 161 +++++++++--- src/lib/__tests__/destination-helpers.test.ts | 56 ++++ src/lib/__tests__/poi-display.test.ts | 25 ++ src/lib/destination-helpers.ts | 158 +++++++++++ src/lib/map-markers.ts | 58 +++++ src/lib/poi-display.ts | 37 +++ src/lib/poi-map-layers.ts | 20 ++ src/models/v4/calls/callResultData.ts | 11 + .../v4/dispatch/getSetUnitStateResultData.ts | 4 + .../v4/dispatch/newCallFormResultData.ts | 4 + .../v4/mapping/getMapDataAndMarkersData.ts | 12 +- src/models/v4/mapping/poiLayerData.ts | 8 + src/models/v4/mapping/poiResult.ts | 7 + src/models/v4/mapping/poiResultData.ts | 14 + src/models/v4/mapping/poiTypeResultData.ts | 8 + src/models/v4/mapping/poiTypesResult.ts | 7 + src/models/v4/mapping/poisResult.ts | 7 + .../getCurrentStatusResultData.ts | 7 +- .../savePersonStatusInput.ts | 1 + .../savePersonsStatusesInput.ts | 1 + .../v4/unitStatus/saveUnitStatusInput.ts | 1 + .../v4/unitStatus/unitStatusResultData.ts | 13 +- src/stores/calls/scheduled-store.ts | 68 +++++ .../dispatch/personnel-actions-store.ts | 45 +++- src/stores/dispatch/unit-actions-store.ts | 39 ++- src/stores/pois/store.ts | 165 ++++++++++++ src/stores/status/store.ts | 41 ++- src/translations/ar.json | 65 +++++ src/translations/en.json | 66 ++++- src/translations/es.json | 64 +++++ yarn.lock | 224 ++++++++-------- 63 files changed, 3044 insertions(+), 408 deletions(-) create mode 100644 src/app/(app)/pois.tsx create mode 100644 src/app/(app)/scheduled-calls.tsx create mode 100644 src/app/poi/[id].tsx create mode 100644 src/app/poi/[id].web.tsx create mode 100644 src/components/calls/scheduled-call-card.tsx create mode 100644 src/components/pois/poi-card.tsx create mode 100644 src/components/pois/poi-detail-screen.tsx create mode 100644 src/lib/__tests__/destination-helpers.test.ts create mode 100644 src/lib/__tests__/poi-display.test.ts create mode 100644 src/lib/destination-helpers.ts create mode 100644 src/lib/map-markers.ts create mode 100644 src/lib/poi-display.ts create mode 100644 src/lib/poi-map-layers.ts create mode 100644 src/models/v4/mapping/poiLayerData.ts create mode 100644 src/models/v4/mapping/poiResult.ts create mode 100644 src/models/v4/mapping/poiResultData.ts create mode 100644 src/models/v4/mapping/poiTypeResultData.ts create mode 100644 src/models/v4/mapping/poiTypesResult.ts create mode 100644 src/models/v4/mapping/poisResult.ts create mode 100644 src/stores/calls/scheduled-store.ts create mode 100644 src/stores/pois/store.ts diff --git a/package.json b/package.json index c5e946e..76d2eb6 100644 --- a/package.json +++ b/package.json @@ -147,37 +147,37 @@ "countly-sdk-react-native-bridge": "^25.4.0", "date-fns": "^4.1.0", "dompurify": "^3.3.1", - "expo": "^54.0.33", + "expo": "~54.0.34", "expo-application": "~7.0.8", - "expo-asset": "~12.0.12", + "expo-asset": "~12.0.13", "expo-audio": "~1.1.1", - "expo-auth-session": "~7.0.10", + "expo-auth-session": "~7.0.11", "expo-av": "~16.0.8", "expo-build-properties": "~1.0.10", "expo-constants": "~18.0.13", - "expo-crypto": "~15.0.8", - "expo-dev-client": "~6.0.20", + "expo-crypto": "~15.0.9", + "expo-dev-client": "~6.0.21", "expo-device": "~8.0.10", "expo-document-picker": "~14.0.8", - "expo-file-system": "~19.0.21", + "expo-file-system": "~19.0.22", "expo-font": "~14.0.11", "expo-image": "~3.0.11", "expo-image-manipulator": "~14.0.8", - "expo-image-picker": "~17.0.10", + "expo-image-picker": "~17.0.11", "expo-keep-awake": "~15.0.8", - "expo-linking": "~8.0.11", + "expo-linking": "~8.0.12", "expo-localization": "~17.0.8", "expo-location": "~19.0.8", "expo-navigation-bar": "~5.0.10", - "expo-notifications": "~0.32.16", + "expo-notifications": "~0.32.17", "expo-router": "~6.0.23", - "expo-screen-orientation": "~9.0.8", + "expo-screen-orientation": "~9.0.9", "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", "expo-task-manager": "~14.0.9", - "expo-web-browser": "~15.0.10", + "expo-web-browser": "~15.0.11", "geojson": "~0.5.0", "he": "^1.2.0", "i18next": "~23.14.0", diff --git a/src/api/calls/calls.ts b/src/api/calls/calls.ts index 519246a..766bd2f 100644 --- a/src/api/calls/calls.ts +++ b/src/api/calls/calls.ts @@ -2,10 +2,12 @@ import { type ActiveCallsResult } from '@/models/v4/calls/activeCallsResult'; import { type CallExtraDataResult } from '@/models/v4/calls/callExtraDataResult'; import { type CallResult } from '@/models/v4/calls/callResult'; import { type SaveCallResult } from '@/models/v4/calls/saveCallResult'; +import { type ScheduledCallsResult } from '@/models/v4/calls/scheduledCallsResult'; import { createApiEndpoint } from '../common/client'; const callsApi = createApiEndpoint('/Calls/GetActiveCalls'); +const pendingScheduledCallsApi = createApiEndpoint('/Calls/GetAllPendingScheduledCalls'); const getCallApi = createApiEndpoint('/Calls/GetCall'); const getCallExtraDataApi = createApiEndpoint('/Calls/GetCallExtraData'); const createCallApi = createApiEndpoint('/Calls/SaveCall'); @@ -18,6 +20,11 @@ export const getCalls = async () => { return response.data; }; +export const getPendingScheduledCalls = async () => { + const response = await pendingScheduledCallsApi.get({ _t: Date.now() }); + return response.data; +}; + export const getCallExtraData = async (callId: string) => { const response = await getCallExtraDataApi.get({ callId: encodeURIComponent(callId), @@ -55,6 +62,7 @@ export interface CreateCallRequest { externalId?: string; referenceId?: string; scheduledOn?: string; + destinationPoiId?: number | null; } export interface UpdateCallRequest { @@ -80,6 +88,7 @@ export interface UpdateCallRequest { linkedCallId?: string; externalId?: string; referenceId?: string; + destinationPoiId?: number | null; } export interface CloseCallRequest { @@ -117,6 +126,7 @@ export const createCall = async (callData: CreateCallRequest) => { Nature: callData.nature, Note: callData.note || '', Address: callData.address || '', + DestinationPoiId: callData.destinationPoiId ?? null, Geolocation: `${callData.latitude?.toString() || ''},${callData.longitude?.toString() || ''}`, Priority: callData.priority, Type: callData.type || '', @@ -166,6 +176,7 @@ export const updateCall = async (callData: UpdateCallRequest) => { Nature: callData.nature, Note: callData.note || '', Address: callData.address || '', + DestinationPoiId: callData.destinationPoiId ?? null, Geolocation: `${callData.latitude?.toString() || ''},${callData.longitude?.toString() || ''}`, Priority: callData.priority, Type: callData.type || '', diff --git a/src/api/dispatch/dispatch.ts b/src/api/dispatch/dispatch.ts index b17bbef..ddd9ec3 100644 --- a/src/api/dispatch/dispatch.ts +++ b/src/api/dispatch/dispatch.ts @@ -1,7 +1,14 @@ import { createApiEndpoint } from '@/api/common/client'; +import { type NewCallFormResult } from '@/models/v4/dispatch/newCallFormResult'; import { type GetSetUnitStateResult } from '@/models/v4/dispatch/getSetUnitStateResult'; -const getSetUnitStateApi = createApiEndpoint('/Dispatch/GetSetUnitState'); +const getNewCallDataApi = createApiEndpoint('/Dispatch/GetNewCallData'); +const getSetUnitStateApi = createApiEndpoint('/Dispatch/GetSetUnitStatusData'); + +export const getNewCallData = async () => { + const response = await getNewCallDataApi.get(); + return response.data; +}; export const getSetUnitState = async (unitId: string) => { const response = await getSetUnitStateApi.get({ @@ -9,3 +16,5 @@ export const getSetUnitState = async (unitId: string) => { }); return response.data; }; + +export const getSetUnitStatusData = getSetUnitState; diff --git a/src/api/mapping/mapping.ts b/src/api/mapping/mapping.ts index aa7d1da..090c146 100644 --- a/src/api/mapping/mapping.ts +++ b/src/api/mapping/mapping.ts @@ -1,11 +1,15 @@ import { type GetMapDataAndMarkersResult } from '@/models/v4/mapping/getMapDataAndMarkersResult'; import { type GetMapLayersResult } from '@/models/v4/mapping/getMapLayersResult'; +import { type PoiResult } from '@/models/v4/mapping/poiResult'; +import { type PoiTypesResult } from '@/models/v4/mapping/poiTypesResult'; +import { type PoisResult } from '@/models/v4/mapping/poisResult'; -import { createApiEndpoint } from '../common/client'; +import { api, createApiEndpoint } from '../common/client'; const getMayLayersApi = createApiEndpoint('/Mapping/GetMayLayers'); - const getMapDataAndMarkersApi = createApiEndpoint('/Mapping/GetMapDataAndMarkers'); +const getPoiTypesApi = createApiEndpoint('/Mapping/GetPoiTypes'); +const getPoisApi = createApiEndpoint('/Mapping/GetPois'); export const getMapDataAndMarkers = async (signal?: AbortSignal) => { const response = await getMapDataAndMarkersApi.get(undefined, signal); @@ -21,3 +25,31 @@ export const getMayLayers = async (type: number, signal?: AbortSignal) => { ); return response.data; }; + +export interface GetPoisOptions { + poiTypeId?: number; + destinationOnly?: boolean; +} + +export const getPoiTypes = async (signal?: AbortSignal) => { + const response = await getPoiTypesApi.get(undefined, signal); + return response.data; +}; + +export const getPois = async (options: GetPoisOptions = {}, signal?: AbortSignal) => { + const response = await getPoisApi.get( + { + ...(typeof options.poiTypeId === 'number' ? { poiTypeId: options.poiTypeId } : {}), + ...(typeof options.destinationOnly === 'boolean' ? { destinationOnly: options.destinationOnly } : {}), + }, + signal + ); + return response.data; +}; + +export const getPoi = async (poiId: number, signal?: AbortSignal) => { + const response = await api.get(`/Mapping/GetPoi/${encodeURIComponent(poiId.toString())}`, { + signal, + }); + return response.data; +}; diff --git a/src/api/personnel/personnelStatuses.ts b/src/api/personnel/personnelStatuses.ts index 42f7747..151c426 100644 --- a/src/api/personnel/personnelStatuses.ts +++ b/src/api/personnel/personnelStatuses.ts @@ -1,26 +1,25 @@ +import { type GetCurrentStatusResult } from '@/models/v4/personnelStatuses/getCurrentStatusResult'; +import { type SavePersonStatusInput } from '@/models/v4/personnelStatuses/savePersonStatusInput'; +import { type SavePersonStatusResult } from '@/models/v4/personnelStatuses/savePersonStatusResult'; +import { type SavePersonsStatusesInput } from '@/models/v4/personnelStatuses/savePersonsStatusesInput'; import { type SavePersonsStatusesResult } from '@/models/v4/personnelStatuses/savePersonsStatusesResult'; import { createApiEndpoint } from '../common/client'; -interface SavePersonsStatusesInput { - UserIds: string[]; - Type: string; - RespondingTo: string; - TimestampUtc: string; - Timestamp: string; - Note: string; - Latitude: string; - Longitude: string; - Accuracy: string; - Altitude: string; - AltitudeAccuracy: string; - Speed: string; - Heading: string; - EventId: string; -} - +const getCurrentStatusApi = createApiEndpoint('/PersonnelStatuses/GetCurrentStatus'); +const savePersonStatusApi = createApiEndpoint('/PersonnelStatuses/SavePersonStatus'); const savePersonsStatusesApi = createApiEndpoint('/PersonnelStatuses/SavePersonsStatuses'); +export const getCurrentStatus = async () => { + const response = await getCurrentStatusApi.get(); + return response.data; +}; + +export const savePersonStatus = async (input: SavePersonStatusInput) => { + const response = await savePersonStatusApi.post(input); + return response.data; +}; + export const savePersonsStatuses = async (input: SavePersonsStatusesInput) => { const response = await savePersonsStatusesApi.post(input); return response.data; diff --git a/src/app/(app)/map.tsx b/src/app/(app)/map.tsx index d82b900..82afdcb 100644 --- a/src/app/(app)/map.tsx +++ b/src/app/(app)/map.tsx @@ -3,7 +3,7 @@ import { Stack, useFocusEffect } from 'expo-router'; import { type Feature, type FeatureCollection, type GeoJsonProperties, type Geometry } from 'geojson'; import { LayersIcon, NavigationIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Animated, Modal, Pressable, ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -18,7 +18,9 @@ import { MapLayerType, useMapLayers } from '@/hooks/use-map-layers'; import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; +import { filterMapPinsByPoiLayers, createDefaultVisiblePoiLayerIds, getPoiMapLayerId } from '@/lib/poi-map-layers'; import { onSortOptions } from '@/lib/utils'; +import { type PoiLayerData } from '@/models/v4/mapping/poiLayerData'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { type GetMapLayersData } from '@/models/v4/mapping/getMapLayersResultData'; import { useCoreStore } from '@/stores/app/core-store'; @@ -37,6 +39,8 @@ export default function Map() { const [isMapReady, setIsMapReady] = useState(false); const [hasUserMovedMap, setHasUserMovedMap] = useState(false); const [mapPins, setMapPins] = useState([]); + const [poiLayers, setPoiLayers] = useState([]); + const [visiblePoiLayerIds, setVisiblePoiLayerIds] = useState>(new Set()); const [selectedPin, setSelectedPin] = useState(null); const [isPinDetailModalOpen, setIsPinDetailModalOpen] = useState(false); const [isLayersPanelOpen, setIsLayersPanelOpen] = useState(false); @@ -70,6 +74,45 @@ export default function Map() { const pulseAnim = useRef(new Animated.Value(1)).current; useMapSignalRUpdates(setMapPins); + const syncPoiLayers = useCallback((nextPoiLayers: PoiLayerData[]) => { + setPoiLayers(nextPoiLayers); + setVisiblePoiLayerIds(createDefaultVisiblePoiLayerIds(nextPoiLayers)); + }, []); + + const togglePoiLayer = useCallback((layerId: string) => { + setVisiblePoiLayerIds((currentLayerIds) => { + const nextLayerIds = new Set(currentLayerIds); + + if (nextLayerIds.has(layerId)) { + nextLayerIds.delete(layerId); + } else { + nextLayerIds.add(layerId); + } + + return nextLayerIds; + }); + }, []); + + const showAllMapLayers = useCallback(() => { + showAllLayers(); + setVisiblePoiLayerIds(createDefaultVisiblePoiLayerIds(poiLayers)); + }, [poiLayers, showAllLayers]); + + const hideAllMapLayers = useCallback(() => { + hideAllLayers(); + setVisiblePoiLayerIds(new Set()); + }, [hideAllLayers]); + + const combinedLayers = useMemo( + () => [ + ...layers.map((layer) => ({ Id: layer.Id, Name: layer.Name, Color: layer.Color, kind: 'custom' as const })), + ...poiLayers.map((layer) => ({ Id: getPoiMapLayerId(layer.PoiTypeId), Name: layer.Name, Color: layer.Color, kind: 'poi' as const })), + ], + [layers, poiLayers] + ); + + const visibleMapPins = useMemo(() => filterMapPinsByPoiLayers(mapPins, visiblePoiLayerIds), [mapPins, visiblePoiLayerIds]); + // Update map style when theme changes useEffect(() => { const newStyle = getMapStyle(); @@ -183,6 +226,7 @@ export default function Map() { if (mapDataAndMarkers && mapDataAndMarkers.Data) { setMapPins(mapDataAndMarkers.Data.MapMakerInfos); + syncPoiLayers(mapDataAndMarkers.Data.PoiLayers ?? []); } } catch (error) { // Don't log aborted requests as errors @@ -206,7 +250,7 @@ export default function Map() { return () => { abortController.abort(); }; - }, []); + }, [syncPoiLayers]); useEffect(() => { Animated.loop( @@ -227,15 +271,15 @@ export default function Map() { // Track when map view is rendered useEffect(() => { - trackEvent('map_view_rendered', { + trackEvent('map_view_rendered', { hasMapPins: mapPins.length > 0, mapPinsCount: mapPins.length, isMapLocked: location.isMapLocked, theme: colorScheme || 'light', - layersCount: layers.length, - visibleLayersCount: visibleLayers.size, + layersCount: combinedLayers.length, + visibleLayersCount: visibleLayers.size + visiblePoiLayerIds.size, }); - }, [trackEvent, mapPins.length, location.isMapLocked, colorScheme, layers.length, visibleLayers.size]); + }, [trackEvent, mapPins.length, location.isMapLocked, colorScheme, combinedLayers.length, visibleLayers.size, visiblePoiLayerIds.size]); const onCameraChanged = (event: any) => { // Only register user interaction if map is not locked @@ -448,35 +492,35 @@ export default function Map() { - - {t('map.show_all')} - - - {t('map.hide_all')} - - - - - {layers.length === 0 ? ( - {isLayersLoading ? t('common.loading') : t('map.no_layers')} - ) : ( - layers.map((layer) => ( - toggleLayer(layer.Id)}> - - - + + {t('map.show_all')} + + + {t('map.hide_all')} + + + + + {combinedLayers.length === 0 ? ( + {isLayersLoading ? t('common.loading') : t('map.no_layers')} + ) : ( + combinedLayers.map((layer) => ( + (layer.kind === 'custom' ? toggleLayer(layer.Id) : togglePoiLayer(layer.Id))}> + + + {layer.Name} - - toggleLayer(layer.Id)} - trackColor={{ false: isDark ? '#4b5563' : '#d1d5db', true: '#3b82f6' }} - thumbColor={visibleLayers.has(layer.Id) ? '#ffffff' : '#f4f3f4'} - /> - - )) - )} + + (layer.kind === 'custom' ? toggleLayer(layer.Id) : togglePoiLayer(layer.Id))} + trackColor={{ false: isDark ? '#4b5563' : '#d1d5db', true: '#3b82f6' }} + thumbColor={(layer.kind === 'custom' ? visibleLayers.has(layer.Id) : visiblePoiLayerIds.has(layer.Id)) ? '#ffffff' : '#f4f3f4'} + /> + + )) + )} @@ -546,7 +590,7 @@ export default function Map() { ) : null} - + {/* Layers Button */} diff --git a/src/app/(app)/map.web.tsx b/src/app/(app)/map.web.tsx index e64d72d..1a69e48 100644 --- a/src/app/(app)/map.web.tsx +++ b/src/app/(app)/map.web.tsx @@ -2,7 +2,7 @@ import { Stack, useFocusEffect } from 'expo-router'; import { type Feature, type FeatureCollection, type GeoJsonProperties, type Geometry } from 'geojson'; import mapboxgl from 'mapbox-gl'; import { useColorScheme } from 'nativewind'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Modal, Pressable, ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; @@ -11,10 +11,14 @@ import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { getMapIconWebUrl, MAP_ICONS } from '@/constants/map-icons'; import { useAnalytics } from '@/hooks/use-analytics'; import { MapLayerType, useMapLayers } from '@/hooks/use-map-layers'; +import { isPoiMarker } from '@/lib/destination-helpers'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; +import { getMapMarkerColor, getMapPinSummary, hasValidMapCoordinates, resolveMapMarkerIconKey } from '@/lib/map-markers'; +import { createDefaultVisiblePoiLayerIds, filterMapPinsByPoiLayers, getPoiMapLayerId } from '@/lib/poi-map-layers'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { type GetMapLayersData } from '@/models/v4/mapping/getMapLayersResultData'; +import { type PoiLayerData } from '@/models/v4/mapping/poiLayerData'; import { useLocationStore } from '@/stores/app/location-store'; // Mapbox GL CSS needs to be injected for web @@ -33,6 +37,8 @@ export default function MapWeb() { const sourceIdsRef = useRef([]); const [isMapReady, setIsMapReady] = useState(false); const [mapPins, setMapPins] = useState([]); + const [poiLayers, setPoiLayers] = useState([]); + const [visiblePoiLayerIds, setVisiblePoiLayerIds] = useState>(new Set()); const [isLayersPanelOpen, setIsLayersPanelOpen] = useState(false); const location = useLocationStore((state) => ({ @@ -43,6 +49,45 @@ export default function MapWeb() { // Map layers hook const { layers, visibleLayers, isLoading: isLayersLoading, fetchLayers, toggleLayer, showAllLayers, hideAllLayers, getVisibleLayerData } = useMapLayers({ initialLayerType: MapLayerType.ALL, autoFetch: true }); + const syncPoiLayers = useCallback((nextPoiLayers: PoiLayerData[]) => { + setPoiLayers(nextPoiLayers); + setVisiblePoiLayerIds(createDefaultVisiblePoiLayerIds(nextPoiLayers)); + }, []); + + const togglePoiLayer = useCallback((layerId: string) => { + setVisiblePoiLayerIds((currentLayerIds) => { + const nextLayerIds = new Set(currentLayerIds); + + if (nextLayerIds.has(layerId)) { + nextLayerIds.delete(layerId); + } else { + nextLayerIds.add(layerId); + } + + return nextLayerIds; + }); + }, []); + + const showAllMapLayers = useCallback(() => { + showAllLayers(); + setVisiblePoiLayerIds(createDefaultVisiblePoiLayerIds(poiLayers)); + }, [poiLayers, showAllLayers]); + + const hideAllMapLayers = useCallback(() => { + hideAllLayers(); + setVisiblePoiLayerIds(new Set()); + }, [hideAllLayers]); + + const combinedLayers = useMemo( + () => [ + ...layers.map((layer) => ({ Id: layer.Id, Name: layer.Name, Color: layer.Color, kind: 'custom' as const })), + ...poiLayers.map((layer) => ({ Id: getPoiMapLayerId(layer.PoiTypeId), Name: layer.Name, Color: layer.Color, kind: 'poi' as const })), + ], + [layers, poiLayers] + ); + + const visibleMapPins = useMemo(() => filterMapPinsByPoiLayers(mapPins, visiblePoiLayerIds), [mapPins, visiblePoiLayerIds]); + // Get map style based on current theme const getMapStyle = useCallback(() => { return colorScheme === 'dark' ? 'mapbox://styles/mapbox/dark-v11' : 'mapbox://styles/mapbox/streets-v12'; @@ -127,6 +172,7 @@ export default function MapWeb() { if (mapDataAndMarkers && mapDataAndMarkers.Data) { setMapPins(mapDataAndMarkers.Data.MapMakerInfos); + syncPoiLayers(mapDataAndMarkers.Data.PoiLayers ?? []); // Center map on the data center if provided if (mapDataAndMarkers.Data.CenterLat && mapDataAndMarkers.Data.CenterLon && map.current) { @@ -164,7 +210,7 @@ export default function MapWeb() { return () => { abortController.abort(); }; - }, []); + }, [syncPoiLayers]); // Update markers when mapPins change useEffect(() => { @@ -175,8 +221,8 @@ export default function MapWeb() { markersRef.current = []; // Add new markers - mapPins.forEach((pin) => { - if (!pin.Latitude || !pin.Longitude) return; + visibleMapPins.forEach((pin) => { + if (!hasValidMapCoordinates(pin)) return; // Create custom marker element const el = document.createElement('div'); @@ -186,28 +232,44 @@ export default function MapWeb() { el.style.alignItems = 'center'; el.style.cursor = 'pointer'; - // Create marker icon const iconContainer = document.createElement('div'); - iconContainer.style.width = '32px'; - iconContainer.style.height = '32px'; + iconContainer.style.display = 'flex'; + iconContainer.style.alignItems = 'center'; + iconContainer.style.justifyContent = 'center'; iconContainer.style.position = 'relative'; - // Get the icon for this pin type - const iconKey = (pin.ImagePath?.toLowerCase() || 'call') as MapIconKey; - const iconData = MAP_ICONS[iconKey] || MAP_ICONS['call']; - - const img = document.createElement('img'); - const imgSrc = getMapIconWebUrl(iconData); - img.src = imgSrc; - img.style.width = '32px'; - img.style.height = '32px'; - img.style.objectFit = 'contain'; - img.alt = pin.Title; - img.onerror = () => { - // Fallback to call icon if image fails to load - img.src = getMapIconWebUrl(MAP_ICONS['call']); - }; - iconContainer.appendChild(img); + if (isPoiMarker(pin.Type)) { + iconContainer.style.width = '22px'; + iconContainer.style.height = '22px'; + iconContainer.style.borderRadius = '999px'; + iconContainer.style.backgroundColor = getMapMarkerColor(pin); + iconContainer.style.border = '2px solid #ffffff'; + iconContainer.style.boxShadow = '0 1px 4px rgba(0, 0, 0, 0.35)'; + + const innerDot = document.createElement('div'); + innerDot.style.width = '8px'; + innerDot.style.height = '8px'; + innerDot.style.borderRadius = '999px'; + innerDot.style.backgroundColor = '#ffffff'; + iconContainer.appendChild(innerDot); + } else { + iconContainer.style.width = '32px'; + iconContainer.style.height = '32px'; + + const iconKey = resolveMapMarkerIconKey(pin) as MapIconKey; + const iconData = MAP_ICONS[iconKey] || MAP_ICONS['call']; + const img = document.createElement('img'); + const imgSrc = getMapIconWebUrl(iconData); + img.src = imgSrc; + img.style.width = '32px'; + img.style.height = '32px'; + img.style.objectFit = 'contain'; + img.alt = pin.Title; + img.onerror = () => { + img.src = getMapIconWebUrl(MAP_ICONS['call']); + }; + iconContainer.appendChild(img); + } el.appendChild(iconContainer); @@ -230,7 +292,7 @@ export default function MapWeb() { const popup = new mapboxgl.Popup({ offset: 25 }).setHTML( `

${pin.Title}

- ${pin.InfoWindowContent ? `

${pin.InfoWindowContent}

` : ''} + ${getMapPinSummary(pin) ? `

${getMapPinSummary(pin)}

` : ''}

${pin.Latitude.toFixed(6)}, ${pin.Longitude.toFixed(6)}

@@ -241,7 +303,7 @@ export default function MapWeb() { markersRef.current.push(marker); }); - }, [mapPins, isMapReady, colorScheme]); + }, [visibleMapPins, isMapReady, colorScheme]); // Update layers when visibility changes useEffect(() => { @@ -340,15 +402,15 @@ export default function MapWeb() { // Track when map view is rendered useEffect(() => { - trackEvent('map_view_rendered', { + trackEvent('map_view_rendered', { hasMapPins: mapPins.length > 0, mapPinsCount: mapPins.length, theme: colorScheme || 'light', platform: 'web', - layersCount: layers.length, - visibleLayersCount: visibleLayers.size, + layersCount: combinedLayers.length, + visibleLayersCount: visibleLayers.size + visiblePoiLayerIds.size, }); - }, [trackEvent, mapPins.length, colorScheme, layers.length, visibleLayers.size]); + }, [trackEvent, mapPins.length, colorScheme, combinedLayers.length, visibleLayers.size, visiblePoiLayerIds.size]); // Render layers panel modal const renderLayersPanel = () => { @@ -366,20 +428,20 @@ export default function MapWeb() { - + {t('map.show_all')} - + {t('map.hide_all')} - {layers.length === 0 ? ( + {combinedLayers.length === 0 ? ( {isLayersLoading ? t('common.loading') : t('map.no_layers')} ) : ( - layers.map((layer) => ( - toggleLayer(layer.Id)}> + combinedLayers.map((layer) => ( + (layer.kind === 'custom' ? toggleLayer(layer.Id) : togglePoiLayer(layer.Id))}> @@ -387,10 +449,10 @@ export default function MapWeb() { toggleLayer(layer.Id)} + value={layer.kind === 'custom' ? visibleLayers.has(layer.Id) : visiblePoiLayerIds.has(layer.Id)} + onValueChange={() => (layer.kind === 'custom' ? toggleLayer(layer.Id) : togglePoiLayer(layer.Id))} trackColor={{ false: isDark ? '#4b5563' : '#d1d5db', true: '#3b82f6' }} - thumbColor={visibleLayers.has(layer.Id) ? '#ffffff' : '#f4f3f4'} + thumbColor={(layer.kind === 'custom' ? visibleLayers.has(layer.Id) : visiblePoiLayerIds.has(layer.Id)) ? '#ffffff' : '#f4f3f4'} /> )) diff --git a/src/app/(app)/pois.tsx b/src/app/(app)/pois.tsx new file mode 100644 index 0000000..b860a64 --- /dev/null +++ b/src/app/(app)/pois.tsx @@ -0,0 +1,168 @@ +import { useFocusEffect } from '@react-navigation/native'; +import { type Href, router } from 'expo-router'; +import { ChevronDownIcon, MapPinned, Search, X } from 'lucide-react-native'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshControl, View } from 'react-native'; + +import { PoiCard } from '@/components/pois/poi-card'; +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 { FlatList } from '@/components/ui/flat-list'; +import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; +import { Select, SelectBackdrop, SelectContent, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select'; +import { getPoiPrimaryDisplayText, getPoiSearchValue } from '@/lib/poi-display'; +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; +import { usePoisStore } from '@/stores/pois/store'; + +type PoiSortOption = 'name-asc' | 'name-desc' | 'type-asc' | 'address-asc'; + +export default function Pois() { + const { t } = useTranslation(); + const { pois, poiTypes, isLoading, error, fetchPoiTypes, fetchPois } = usePoisStore(); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedPoiTypeId, setSelectedPoiTypeId] = useState('all'); + const [sortOption, setSortOption] = useState('name-asc'); + const [refreshing, setRefreshing] = useState(false); + + useFocusEffect( + useCallback(() => { + fetchPoiTypes(); + fetchPois(); + }, [fetchPoiTypes, fetchPois]) + ); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + + try { + await Promise.all([fetchPoiTypes(true), fetchPois(true)]); + } finally { + setRefreshing(false); + } + }, [fetchPoiTypes, fetchPois]); + + const filteredPois = useMemo(() => { + const normalizedQuery = searchQuery.trim().toLowerCase(); + + return [...pois] + .filter((poi) => (selectedPoiTypeId === 'all' ? true : poi.PoiTypeId.toString() === selectedPoiTypeId)) + .filter((poi) => (normalizedQuery ? getPoiSearchValue(poi).includes(normalizedQuery) : true)) + .sort((left, right) => sortPois(left, right, sortOption)); + }, [pois, searchQuery, selectedPoiTypeId, sortOption]); + + const typeValue = poiTypes.find((poiType) => poiType.PoiTypeId.toString() === selectedPoiTypeId)?.Name ?? t('pois.all_types'); + const sortValue = t(`pois.sort_options.${sortOption}`); + + if (isLoading && pois.length === 0) { + return ( + + + + + ); + } + + return ( + + + + + + + + + {searchQuery ? ( + setSearchQuery('')}> + + + ) : null} + + + + + + + + + + + {error && pois.length === 0 ? ( + + ) : ( + + testID="pois-list" + data={filteredPois} + keyExtractor={(item) => item.PoiId.toString()} + renderItem={({ item }) => router.push(`/poi/${poi.PoiId}` as Href)} />} + contentContainerStyle={{ paddingBottom: 24 }} + refreshControl={} + ListEmptyComponent={ + + } + /> + )} + + + ); +} + +const sortPois = (left: PoiResultData, right: PoiResultData, sortOption: PoiSortOption) => { + switch (sortOption) { + case 'name-desc': + return getSortValue(right, 'name').localeCompare(getSortValue(left, 'name')); + case 'type-asc': + return getSortValue(left, 'type').localeCompare(getSortValue(right, 'type')) || getSortValue(left, 'name').localeCompare(getSortValue(right, 'name')); + case 'address-asc': + return getSortValue(left, 'address').localeCompare(getSortValue(right, 'address')) || getSortValue(left, 'name').localeCompare(getSortValue(right, 'name')); + case 'name-asc': + default: + return getSortValue(left, 'name').localeCompare(getSortValue(right, 'name')); + } +}; + +const getSortValue = (poi: PoiResultData, field: 'name' | 'type' | 'address') => { + switch (field) { + case 'type': + return (poi.PoiTypeName || '').toLowerCase(); + case 'address': + return (poi.Address || getPoiPrimaryDisplayText(poi)).toLowerCase(); + case 'name': + default: + return getPoiPrimaryDisplayText(poi).toLowerCase(); + } +}; diff --git a/src/app/(app)/scheduled-calls.tsx b/src/app/(app)/scheduled-calls.tsx new file mode 100644 index 0000000..363f4fe --- /dev/null +++ b/src/app/(app)/scheduled-calls.tsx @@ -0,0 +1,148 @@ +import { useFocusEffect } from '@react-navigation/native'; +import { type Href, router, Stack } from 'expo-router'; +import { CalendarClockIcon, Search, X } from 'lucide-react-native'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, RefreshControl, View } from 'react-native'; + +import { getCallExtraData } from '@/api/calls/calls'; +import { ScheduledCallCard } from '@/components/calls/scheduled-call-card'; +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { Box } from '@/components/ui/box'; +import { FlatList } from '@/components/ui/flat-list'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; +import { useAnalytics } from '@/hooks/use-analytics'; +import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { type DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData'; +import { useScheduledCallsStore } from '@/stores/calls/scheduled-store'; +import { useCallsStore } from '@/stores/calls/store'; + +export default function ScheduledCalls() { + const { scheduledCalls, isLoading, error, fetchScheduledCalls } = useScheduledCallsStore(); + const { fetchCallPriorities, callPriorities } = useCallsStore(); + const { t } = useTranslation(); + const { trackEvent } = useAnalytics(); + const [searchQuery, setSearchQuery] = useState(''); + const [callDispatchesMap, setCallDispatchesMap] = useState>({}); + const [loadingDispatchIds, setLoadingDispatchIds] = useState>(new Set()); + const fetchedIdsRef = useRef>(new Set()); + + useFocusEffect( + useCallback(() => { + fetchCallPriorities(); + fetchScheduledCalls(); + }, [fetchCallPriorities, fetchScheduledCalls]) + ); + + useEffect(() => { + trackEvent('scheduled_calls_view_rendered', { + callsCount: scheduledCalls.length, + }); + }, [trackEvent, scheduledCalls.length]); + + useEffect(() => { + const toFetch = scheduledCalls.filter((c) => !fetchedIdsRef.current.has(c.CallId)).map((c) => c.CallId); + + if (toFetch.length === 0) return; + + toFetch.forEach((id) => fetchedIdsRef.current.add(id)); + + setLoadingDispatchIds((prev) => { + const next = new Set(prev); + toFetch.forEach((id) => next.add(id)); + return next; + }); + + Promise.all( + toFetch.map((callId) => + getCallExtraData(callId) + .then((res) => ({ callId, dispatches: res?.Data?.Dispatches ?? ([] as DispatchedEventResultData[]) })) + .catch(() => ({ callId, dispatches: [] as DispatchedEventResultData[] })) + ) + ).then((results) => { + setCallDispatchesMap((prev) => { + const next = { ...prev }; + results.forEach(({ callId, dispatches }) => { + next[callId] = dispatches; + }); + return next; + }); + setLoadingDispatchIds((prev) => { + const next = new Set(prev); + toFetch.forEach((id) => next.delete(id)); + return next; + }); + }); + }, [scheduledCalls]); + + const handleRefresh = useCallback(() => { + fetchedIdsRef.current = new Set(); + setCallDispatchesMap({}); + setLoadingDispatchIds(new Set()); + fetchScheduledCalls(); + }, [fetchScheduledCalls]); + + const filteredCalls = scheduledCalls.filter( + (call) => + call.CallId.toLowerCase().includes(searchQuery.toLowerCase()) || + (call.Nature?.toLowerCase() || '').includes(searchQuery.toLowerCase()) || + (call.Name?.toLowerCase() || '').includes(searchQuery.toLowerCase()) || + (call.Address?.toLowerCase() || '').includes(searchQuery.toLowerCase()) || + (call.Number?.toLowerCase() || '').includes(searchQuery.toLowerCase()) + ); + + const renderContent = () => { + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + + testID="scheduled-calls-list" + data={filteredCalls} + renderItem={({ item }: { item: CallResultData }) => ( + router.push(`/call/${item.CallId}` as Href)}> + p.Id === item.Priority)} dispatches={callDispatchesMap[item.CallId]} isLoadingDispatches={loadingDispatchIds.has(item.CallId)} /> + + )} + keyExtractor={(item: CallResultData) => item.CallId} + refreshControl={} + ListEmptyComponent={} + contentContainerStyle={{ paddingBottom: 20 }} + /> + ); + }; + + return ( + + + + + + + + + + {searchQuery ? ( + setSearchQuery('')}> + + + ) : null} + + {renderContent()} + + + ); +} diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index ce77726..912bf9a 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -286,6 +286,14 @@ export default function CallDetail() { {t('call_detail.timestamp')} {format(new Date(call.LoggedOn), 'MMM d, h:mm a')} + {(call.ScheduledOn || call.ScheduledOnUtc) ? ( + + {t('call_detail.scheduled_on')} + + {format(new Date(call.ScheduledOn || call.ScheduledOnUtc), 'MMM d, yyyy h:mm a')} + + + ) : null} {t('call_detail.type')} {call.Type} @@ -294,6 +302,24 @@ export default function CallDetail() { {t('call_detail.address')} {call.Address} + {call.DestinationName ? ( + + {t('call_detail.destination')} + {call.DestinationName} + + ) : null} + {call.DestinationTypeName ? ( + + {t('call_detail.destination_type')} + {call.DestinationTypeName} + + ) : null} + {call.DestinationAddress ? ( + + {t('call_detail.destination_address')} + {call.DestinationAddress} + + ) : null} {t('call_detail.note')} diff --git a/src/app/call/[id].web.tsx b/src/app/call/[id].web.tsx index c76acda..69604b1 100644 --- a/src/app/call/[id].web.tsx +++ b/src/app/call/[id].web.tsx @@ -236,8 +236,19 @@ export default function CallDetailWeb() { + {(call.ScheduledOn || call.ScheduledOnUtc) ? ( + + ) : null} + + + {t('call_detail.note')} diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx index 2c8c8e5..7469dcb 100644 --- a/src/app/call/[id]/edit.tsx +++ b/src/app/call/[id]/edit.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'; import { ScrollView, TouchableOpacity, View } from 'react-native'; import * as z from 'zod'; +import { getNewCallData } from '@/api/dispatch/dispatch'; import { saveUdfValues } from '@/api/userDefinedFields/userDefinedFields'; import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal'; import { UdfFieldsRenderer } from '@/components/calls/udf-fields-renderer'; @@ -26,6 +27,8 @@ import { Text } from '@/components/ui/text'; import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { useToast } from '@/components/ui/toast'; import { useAnalytics } from '@/hooks/use-analytics'; +import { getPoiDestinationOptionLabel } from '@/lib/poi-display'; +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallDetailStore } from '@/stores/calls/detail-store'; @@ -37,6 +40,7 @@ const formSchema = z.object({ name: z.string().min(1, 'Name is required'), nature: z.string().min(1, 'Nature is required'), note: z.string().optional(), + destinationPoiId: z.string().optional(), address: z.string().optional(), coordinates: z.string().optional(), what3words: z.string().optional(), @@ -58,6 +62,8 @@ const formSchema = z.object({ type FormValues = z.infer; +const NO_DESTINATION_VALUE = '__none__'; + interface GeocodingResult { place_id: string; formatted_address: string; @@ -94,6 +100,8 @@ export default function EditCall() { const [isGeocodingCoordinates, setIsGeocodingCoordinates] = useState(false); const [isGeocodingWhat3Words, setIsGeocodingWhat3Words] = useState(false); const [addressResults, setAddressResults] = useState([]); + const [destinationPois, setDestinationPois] = useState([]); + const [isLoadingDestinationPois, setIsLoadingDestinationPois] = useState(false); const [dispatchSelection, setDispatchSelection] = useState({ everyone: false, users: [], @@ -119,6 +127,7 @@ export default function EditCall() { name: '', nature: '', note: '', + destinationPoiId: '', address: '', coordinates: '', what3words: '', @@ -147,6 +156,30 @@ export default function EditCall() { } }, [fetchCallPriorities, fetchCallTypes, fetchCallDetail, callId]); + useEffect(() => { + let isMounted = true; + + setIsLoadingDestinationPois(true); + getNewCallData() + .then((result) => { + if (isMounted) { + setDestinationPois(result?.Data?.DestinationPois || []); + } + }) + .catch((error) => { + console.error('Failed to load destination POIs:', error); + }) + .finally(() => { + if (isMounted) { + setIsLoadingDestinationPois(false); + } + }); + + return () => { + isMounted = false; + }; + }, []); + // Pre-populate form when call data is loaded useEffect(() => { if (call) { @@ -183,6 +216,7 @@ export default function EditCall() { name: call.Name || '', nature: call.Nature || '', note: call.Note || '', + destinationPoiId: call.DestinationPoiId ? call.DestinationPoiId.toString() : '', address: call.Address || '', coordinates: call.Geolocation || '', what3words: '', @@ -242,6 +276,7 @@ export default function EditCall() { priority: priority?.Id || 0, type: type?.Name || '', note: data.note, + destinationPoiId: data.destinationPoiId ? Number(data.destinationPoiId) : null, address: data.address, latitude: data.latitude, longitude: data.longitude, @@ -641,6 +676,35 @@ export default function EditCall() { )} + + + + {t('calls.destination_poi')} + + ( + + )} + /> + {isLoadingDestinationPois ? {t('calls.loading_destination_pois')} : null} + {!isLoadingDestinationPois && destinationPois.length === 0 ? {t('calls.no_destination_pois_available')} : null} + diff --git a/src/app/call/[id]/edit.web.tsx b/src/app/call/[id]/edit.web.tsx index c659546..676b3d9 100644 --- a/src/app/call/[id]/edit.web.tsx +++ b/src/app/call/[id]/edit.web.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'; import { Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; import * as z from 'zod'; +import { getNewCallData } from '@/api/dispatch/dispatch'; import { saveUdfValues } from '@/api/userDefinedFields/userDefinedFields'; import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal'; import { UdfFieldsRenderer } from '@/components/calls/udf-fields-renderer'; @@ -20,6 +21,8 @@ import { Card } from '@/components/ui/card'; import { Text } from '@/components/ui/text'; import { useToast } from '@/components/ui/toast'; import { useAnalytics } from '@/hooks/use-analytics'; +import { getPoiDestinationOptionLabel } from '@/lib/poi-display'; +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallDetailStore } from '@/stores/calls/detail-store'; @@ -31,6 +34,7 @@ const formSchema = z.object({ name: z.string().min(1, 'Name is required'), nature: z.string().min(1, 'Nature is required'), note: z.string().optional(), + destinationPoiId: z.string().optional(), address: z.string().optional(), coordinates: z.string().optional(), what3words: z.string().optional(), @@ -52,6 +56,8 @@ const formSchema = z.object({ type FormValues = z.infer; +const NO_DESTINATION_VALUE = '__none__'; + interface GeocodingResult { place_id: string; formatted_address: string; @@ -142,9 +148,10 @@ interface WebSelectProps { options: Array<{ id: string | number; name: string; color?: string }>; error?: string; required?: boolean; + useIdValue?: boolean; } -const WebSelect: React.FC = ({ label, placeholder, value, onChange, options, error, required = false }) => { +const WebSelect: React.FC = ({ label, placeholder, value, onChange, options, error, required = false, useIdValue = false }) => { const { colorScheme } = useColorScheme(); const isDark = colorScheme === 'dark'; @@ -161,7 +168,7 @@ const WebSelect: React.FC = ({ label, placeholder, value, onChan > {options.map((option) => ( - ))} @@ -189,6 +196,8 @@ export default function EditCallWeb() { const [showAddressSelection, setShowAddressSelection] = useState(false); const [isGeocodingAddress, setIsGeocodingAddress] = useState(false); const [addressResults, setAddressResults] = useState([]); + const [destinationPois, setDestinationPois] = useState([]); + const [isLoadingDestinationPois, setIsLoadingDestinationPois] = useState(false); const [dispatchSelection, setDispatchSelection] = useState({ everyone: false, users: [], @@ -221,6 +230,7 @@ export default function EditCallWeb() { name: '', nature: '', note: '', + destinationPoiId: '', address: '', coordinates: '', what3words: '', @@ -249,6 +259,30 @@ export default function EditCallWeb() { if (callId) fetchCallDetail(callId); }, [fetchCallPriorities, fetchCallTypes, fetchCallDetail, callId]); + useEffect(() => { + let isMounted = true; + + setIsLoadingDestinationPois(true); + getNewCallData() + .then((result) => { + if (isMounted) { + setDestinationPois(result?.Data?.DestinationPois || []); + } + }) + .catch((error) => { + console.error('Failed to load destination POIs:', error); + }) + .finally(() => { + if (isMounted) { + setIsLoadingDestinationPois(false); + } + }); + + return () => { + isMounted = false; + }; + }, []); + // Pre-populate form when call data is loaded useEffect(() => { if (call) { @@ -285,6 +319,7 @@ export default function EditCallWeb() { name: call.Name || '', nature: call.Nature || '', note: call.Note || '', + destinationPoiId: call.DestinationPoiId ? call.DestinationPoiId.toString() : '', address: call.Address || '', coordinates: call.Geolocation || '', what3words: '', @@ -372,8 +407,9 @@ export default function EditCallWeb() { name: data.name, nature: data.nature, priority: priority?.Id || 0, - type: type?.Name || '', + type: type?.Id || '', note: data.note, + destinationPoiId: data.destinationPoiId ? Number(data.destinationPoiId) : null, address: data.address, latitude: data.latitude, longitude: data.longitude, @@ -761,6 +797,33 @@ export default function EditCallWeb() { )} + + ( + onChange(selectedValue === NO_DESTINATION_VALUE ? '' : selectedValue)} + useIdValue + options={[ + { id: NO_DESTINATION_VALUE, name: t('calls.no_destination') }, + ...destinationPois.map((poi) => ({ + id: poi.PoiId, + name: getPoiDestinationOptionLabel(poi), + })), + ]} + /> + )} + /> + {isLoadingDestinationPois ? ( + {t('calls.loading_destination_pois')} + ) : null} + {!isLoadingDestinationPois && destinationPois.length === 0 ? ( + {t('calls.no_destination_pois_available')} + ) : null} {/* Dispatch Card */} diff --git a/src/app/call/new/index.tsx b/src/app/call/new/index.tsx index cd609b7..90d804e 100644 --- a/src/app/call/new/index.tsx +++ b/src/app/call/new/index.tsx @@ -13,6 +13,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as z from 'zod'; import { createCall } from '@/api/calls/calls'; +import { getNewCallData } from '@/api/dispatch/dispatch'; import { getNewCallForm } from '@/api/forms/forms'; import { saveUdfValues } from '@/api/userDefinedFields/userDefinedFields'; import { CallFormRenderer } from '@/components/calls/call-form-renderer'; @@ -37,9 +38,11 @@ import { Text } from '@/components/ui/text'; import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { useAnalytics } from '@/hooks/use-analytics'; import { useToast } from '@/hooks/use-toast'; +import { getPoiDestinationOptionLabel } from '@/lib/poi-display'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { type ContactResultData } from '@/models/v4/contacts/contactResultData'; import { type FormResultData } from '@/models/v4/forms/formResultData'; +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; @@ -50,6 +53,7 @@ const formSchema = z.object({ name: z.string().min(1, { message: 'Name is required' }), nature: z.string().min(1, { message: 'Nature is required' }), note: z.string().optional(), + destinationPoiId: z.string().optional(), address: z.string().optional(), coordinates: z.string().optional(), what3words: z.string().optional(), @@ -73,6 +77,8 @@ const formSchema = z.object({ type FormValues = z.infer; +const NO_DESTINATION_VALUE = '__none__'; + // Google Maps Geocoding API response types interface GeocodingResult { formatted_address: string; @@ -130,6 +136,8 @@ export default function NewCall() { const [showLinkedCallsModal, setShowLinkedCallsModal] = useState(false); const [callFormData, setCallFormData] = useState(null); const [callForm, setCallForm] = useState(null); + const [destinationPois, setDestinationPois] = useState([]); + const [isLoadingDestinationPois, setIsLoadingDestinationPois] = useState(false); const [udfValues, setUdfValues] = useState([]); const [selectedProtocols, setSelectedProtocols] = useState([]); const [linkedCall, setLinkedCall] = useState<{ callId: string; number: string; name: string } | null>(null); @@ -181,6 +189,7 @@ export default function NewCall() { name: '', nature: '', note: '', + destinationPoiId: '', address: '', coordinates: '', what3words: '', @@ -214,6 +223,30 @@ export default function NewCall() { .catch(() => {}); }, []); + useEffect(() => { + let isMounted = true; + + setIsLoadingDestinationPois(true); + getNewCallData() + .then((result) => { + if (isMounted) { + setDestinationPois(result?.Data?.DestinationPois || []); + } + }) + .catch((error) => { + console.error('Failed to load destination POIs:', error); + }) + .finally(() => { + if (isMounted) { + setIsLoadingDestinationPois(false); + } + }); + + return () => { + isMounted = false; + }; + }, []); + // Track when new call view is rendered useEffect(() => { trackEvent('new_call_view_rendered', { @@ -252,6 +285,7 @@ export default function NewCall() { priority: priority.Id, type: type.Name, note: data.note, + destinationPoiId: data.destinationPoiId ? Number(data.destinationPoiId) : null, address: data.address, latitude: data.latitude, longitude: data.longitude, @@ -952,6 +986,35 @@ export default function NewCall() { )} + + + + {t('calls.destination_poi')} + + ( + + )} + /> + {isLoadingDestinationPois ? {t('calls.loading_destination_pois')} : null} + {!isLoadingDestinationPois && destinationPois.length === 0 ? {t('calls.no_destination_pois_available')} : null} + ) : null} diff --git a/src/app/call/new/index.web.tsx b/src/app/call/new/index.web.tsx index 2431085..de44125 100644 --- a/src/app/call/new/index.web.tsx +++ b/src/app/call/new/index.web.tsx @@ -10,6 +10,7 @@ import { Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 're import * as z from 'zod'; import { createCall } from '@/api/calls/calls'; +import { getNewCallData } from '@/api/dispatch/dispatch'; import { getNewCallForm } from '@/api/forms/forms'; import { saveUdfValues } from '@/api/userDefinedFields/userDefinedFields'; import { CallFormRenderer } from '@/components/calls/call-form-renderer'; @@ -31,9 +32,11 @@ import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; import { useToast } from '@/hooks/use-toast'; +import { getPoiDestinationOptionLabel } from '@/lib/poi-display'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { type ContactResultData } from '@/models/v4/contacts/contactResultData'; import { type FormResultData } from '@/models/v4/forms/formResultData'; +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; @@ -44,6 +47,7 @@ const formSchema = z.object({ name: z.string().min(1, { message: 'Name is required' }), nature: z.string().min(1, { message: 'Nature is required' }), note: z.string().optional(), + destinationPoiId: z.string().optional(), address: z.string().optional(), coordinates: z.string().optional(), what3words: z.string().optional(), @@ -68,6 +72,8 @@ const formSchema = z.object({ type FormValues = z.infer; +const NO_DESTINATION_VALUE = '__none__'; + // Google Maps Geocoding API response types interface GeocodingResult { formatted_address: string; @@ -220,9 +226,10 @@ interface WebSelectProps { options: Array<{ id: string | number; name: string; color?: string }>; error?: string; required?: boolean; + useIdValue?: boolean; } -const WebSelect: React.FC = ({ label, placeholder, value, onChange, options, error, required = false }) => { +const WebSelect: React.FC = ({ label, placeholder, value, onChange, options, error, required = false, useIdValue = false }) => { const { colorScheme } = useColorScheme(); const isDark = colorScheme === 'dark'; @@ -243,7 +250,7 @@ const WebSelect: React.FC = ({ label, placeholder, value, onChan