diff --git a/__mocks__/lucide-react-native.ts b/__mocks__/lucide-react-native.ts index 9ab8f71..8978ef2 100644 --- a/__mocks__/lucide-react-native.ts +++ b/__mocks__/lucide-react-native.ts @@ -49,3 +49,4 @@ export const UserIcon = mockIcon; export const Users = mockIcon; export const UsersIcon = mockIcon; export const X = mockIcon; +export const Menu = mockIcon; diff --git a/src/api/calls/calls.ts b/src/api/calls/calls.ts index 9f74b97..065e8dc 100644 --- a/src/api/calls/calls.ts +++ b/src/api/calls/calls.ts @@ -36,6 +36,7 @@ export interface CreateCallRequest { nature: string; note?: string; address?: string; + destinationPoiId?: number | null; latitude?: number; longitude?: number; priority: number; @@ -57,6 +58,7 @@ export interface UpdateCallRequest { nature: string; note?: string; address?: string; + destinationPoiId?: number | null; latitude?: number; longitude?: number; priority: number; @@ -111,6 +113,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 || '', @@ -155,6 +158,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/index.ts b/src/api/dispatch/index.ts new file mode 100644 index 0000000..c4e5cb7 --- /dev/null +++ b/src/api/dispatch/index.ts @@ -0,0 +1,22 @@ +import { type GetSetUnitStateResult } from '@/models/v4/dispatch/getSetUnitStateResult'; +import { type NewCallFormResult } from '@/models/v4/dispatch/newCallFormResult'; + +import { createApiEndpoint } from '../common/client'; + +const getNewCallDataApi = createApiEndpoint('/Dispatch/GetNewCallData'); +const getSetUnitStatusDataApi = createApiEndpoint('/Dispatch/GetSetUnitStatusData'); + +export const getNewCallData = async (signal?: AbortSignal) => { + const response = await getNewCallDataApi.get(undefined, signal); + return response.data; +}; + +export const getSetUnitStatusData = async (unitId: string, signal?: AbortSignal) => { + const response = await getSetUnitStatusDataApi.get( + { + unitId: unitId, + }, + signal + ); + return response.data; +}; diff --git a/src/api/mapping/mapping.ts b/src/api/mapping/mapping.ts index 36a4a82..83ca837 100644 --- a/src/api/mapping/mapping.ts +++ b/src/api/mapping/mapping.ts @@ -1,11 +1,16 @@ 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 PoisResult } from '@/models/v4/mapping/poisResult'; +import { type PoiTypesResult } from '@/models/v4/mapping/poiTypesResult'; import { createApiEndpoint } from '../common/client'; const getMayLayersApi = createApiEndpoint('/Mapping/GetMapLayers'); 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); @@ -18,3 +23,34 @@ export const getMayLayers = async (type: number) => { }); 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 params: Record = {}; + + if (options.poiTypeId !== undefined) { + params.poiTypeId = options.poiTypeId; + } + + if (options.destinationOnly !== undefined) { + params.destinationOnly = options.destinationOnly; + } + + const response = await getPoisApi.get(Object.keys(params).length > 0 ? params : undefined, signal); + return response.data; +}; + +export const getPoi = async (poiId: number, signal?: AbortSignal) => { + const getPoiApi = createApiEndpoint(`/Mapping/GetPoi/${encodeURIComponent(poiId)}`); + const response = await getPoiApi.get(undefined, signal); + return response.data; +}; diff --git a/src/app/(app)/__tests__/map.test.tsx b/src/app/(app)/__tests__/map.test.tsx index 2866b42..108c4c3 100644 --- a/src/app/(app)/__tests__/map.test.tsx +++ b/src/app/(app)/__tests__/map.test.tsx @@ -9,6 +9,11 @@ import HomeMap from '../map'; // Mock NativeWind and CSS Interop jest.mock('nativewind', () => ({ cssInterop: jest.fn(), + useColorScheme: () => ({ + colorScheme: 'light', + setColorScheme: jest.fn(), + toggleColorScheme: jest.fn(), + }), })); jest.mock('react-native-css-interop', () => ({ @@ -302,11 +307,17 @@ jest.mock('@/stores/toast/store', () => ({ })); // Mock expo-router +const mockRouterPush = jest.fn(); + jest.mock('expo-router', () => ({ useFocusEffect: (callback: () => void) => { const mockReact = require('react'); mockReact.useEffect(callback, []); }, + useLocalSearchParams: () => ({}), + useRouter: () => ({ + push: mockRouterPush, + }), })); describe('HomeMap', () => { @@ -315,6 +326,7 @@ describe('HomeMap', () => { beforeEach(() => { jest.clearAllMocks(); + mockRouterPush.mockReset(); mockTrackEvent.mockReset(); mockTrackEvent.mockReset(); mockTrackEvent.mockReset(); diff --git a/src/app/(app)/map.tsx b/src/app/(app)/map.tsx index 5aae3f2..c4b64e7 100644 --- a/src/app/(app)/map.tsx +++ b/src/app/(app)/map.tsx @@ -1,364 +1,120 @@ -import Mapbox from '@rnmapbox/maps'; -import { useFocusEffect } from 'expo-router'; -import { NavigationIcon } from 'lucide-react-native'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Menu } from 'lucide-react-native'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Animated, StyleSheet, TouchableOpacity, useWindowDimensions, View } from 'react-native'; +import { TouchableOpacity, useWindowDimensions, View } from 'react-native'; -import { getMapDataAndMarkers } from '@/api/mapping/mapping'; -import MapPins from '@/components/maps/map-pins'; -import PinDetailModal from '@/components/maps/pin-detail-modal'; +import MapPanel from '@/components/maps/map-panel'; +import PoiListPanel from '@/components/maps/poi-list-panel'; import { SideMenu } from '@/components/sidebar/side-menu'; import { Button, ButtonText } from '@/components/ui/button'; import { Drawer, DrawerBackdrop, DrawerBody, DrawerContent, DrawerFooter } from '@/components/ui/drawer/index'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; -import { useAnalytics } from '@/hooks/use-analytics'; -import { useAppLifecycle } from '@/hooks/use-app-lifecycle'; -import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates'; -import { logger } from '@/lib/logging'; -import { onSortOptions } from '@/lib/utils'; -import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; -import { locationService } from '@/services/location'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useLocationStore } from '@/stores/app/location-store'; -import { useToastStore } from '@/stores/toast/store'; +import { SharedTabs, type TabItem } from '@/components/ui/shared-tabs'; +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; +import { usePoiStore } from '@/stores/poi/store'; export default function HomeMap() { const { t } = useTranslation(); - const { trackEvent } = useAnalytics(); + const router = useRouter(); + const { tab, poiId } = useLocalSearchParams<{ tab?: string | string[]; poiId?: string | string[] }>(); const { width, height } = useWindowDimensions(); const isLandscape = width > height; const [isSideMenuOpen, setIsSideMenuOpen] = useState(false); - const mapRef = useRef(null); - const cameraRef = useRef(null); - const [isMapReady, setIsMapReady] = useState(false); - const [hasUserMovedMap, setHasUserMovedMap] = useState(false); - const [mapPins, setMapPins] = useState([]); - const [selectedPin, setSelectedPin] = useState(null); - const [isPinDetailModalOpen, setIsPinDetailModalOpen] = useState(false); - const { isActive } = useAppLifecycle(); - const location = useLocationStore((state) => ({ - latitude: state.latitude, - longitude: state.longitude, - heading: state.heading, - isMapLocked: state.isMapLocked, - })); - - const _mapOptions = Object.keys(Mapbox.StyleURL) - .map((key) => { - return { - label: key, - data: (Mapbox.StyleURL as any)[key], - }; - }) - .sort(onSortOptions); - - const [styleURL] = useState({ styleURL: _mapOptions[0]?.data }); - - const pulseAnim = useRef(new Animated.Value(1)).current; - useMapSignalRUpdates(setMapPins); - - // Handle navigation focus - reset map state when user navigates back to map page - useFocusEffect( - useCallback(() => { - // Track analytics when view becomes visible - trackEvent('map_viewed', { - timestamp: new Date().toISOString(), - isMapLocked: location.isMapLocked, - hasLocation: location.latitude != null && location.longitude != null, - }); - - // Reset hasUserMovedMap when navigating back to map - setHasUserMovedMap(false); - - // Reset camera to current location when navigating back to map - if (isMapReady && location.latitude != null && location.longitude != null) { - const cameraConfig: any = { - centerCoordinate: [location.longitude, location.latitude], - zoomLevel: location.isMapLocked ? 16 : 12, - animationDuration: 1000, - heading: 0, - pitch: 0, - }; - - // Add heading and pitch for navigation mode when locked - if (location.isMapLocked && location.heading !== null && location.heading !== undefined) { - cameraConfig.heading = location.heading; - cameraConfig.pitch = 45; - } - - cameraRef.current?.setCamera(cameraConfig); - - logger.info({ - message: 'Home Map focused, resetting camera to current location', - context: { - latitude: location.latitude, - longitude: location.longitude, - isMapLocked: location.isMapLocked, - }, - }); - } - }, [isMapReady, location.latitude, location.longitude, location.isMapLocked, location.heading, trackEvent]) - ); - - useEffect(() => { - // Location tracking is now handled at the app level when user is logged in - // No need to start/stop here as it should persist across screen changes - }, []); - - useEffect(() => { - if (isMapReady && location.latitude != null && location.longitude != null) { - logger.info({ - message: 'Location updated and map is ready', - context: { - latitude: location.latitude, - longitude: location.longitude, - heading: location.heading, - isMapLocked: location.isMapLocked, - }, - }); - - // When map is locked, always follow the location - // When map is unlocked, only follow if user hasn't moved the map - if (location.isMapLocked || !hasUserMovedMap) { - const cameraConfig: any = { - centerCoordinate: [location.longitude, location.latitude], - zoomLevel: location.isMapLocked ? 16 : 12, - animationDuration: location.isMapLocked ? 500 : 1000, - }; - - // Add heading and pitch for navigation mode when locked - if (location.isMapLocked && location.heading !== null && location.heading !== undefined) { - cameraConfig.heading = location.heading; - cameraConfig.pitch = 45; - } - - cameraRef.current?.setCamera(cameraConfig); - } - } - }, [isMapReady, location.latitude, location.longitude, location.heading, location.isMapLocked, hasUserMovedMap]); - - // Reset hasUserMovedMap when map gets locked and reset camera when unlocked - useEffect(() => { - if (location.isMapLocked) { - setHasUserMovedMap(false); - } else { - // When exiting locked mode, reset camera to normal view and reset user interaction state - setHasUserMovedMap(false); - - if (isMapReady && location.latitude != null && location.longitude != null) { - cameraRef.current?.setCamera({ - centerCoordinate: [location.longitude, location.latitude], - zoomLevel: 12, - heading: 0, - pitch: 0, - animationDuration: 1000, - }); - logger.info({ - message: 'Map unlocked, resetting camera to normal view and user interaction state', - context: { - latitude: location.latitude, - longitude: location.longitude, - }, - }); - } - } - }, [isMapReady, location.isMapLocked, location.latitude, location.longitude]); - - useEffect(() => { - const fetchMapDataAndMarkers = async () => { - const mapDataAndMarkers = await getMapDataAndMarkers(); - - if (mapDataAndMarkers && mapDataAndMarkers.Data) { - setMapPins(mapDataAndMarkers.Data.MapMakerInfos); - } - }; - - fetchMapDataAndMarkers(); - }, []); + const fetchPoisData = usePoiStore((state) => state.fetchPoisData); useEffect(() => { - Animated.loop( - Animated.sequence([ - Animated.timing(pulseAnim, { - toValue: 1.2, - duration: 1000, - useNativeDriver: true, - }), - Animated.timing(pulseAnim, { - toValue: 1, - duration: 1000, - useNativeDriver: true, - }), - ]) - ).start(); - }, [pulseAnim]); - - const onCameraChanged = (event: any) => { - // Only register user interaction if map is not locked - if (event.properties.isUserInteraction && !location.isMapLocked) { - setHasUserMovedMap(true); - } - }; - - const handleRecenterMap = () => { - if (location.latitude != null && location.longitude != null) { - const cameraConfig: any = { - centerCoordinate: [location.longitude, location.latitude], - zoomLevel: location.isMapLocked ? 16 : 12, - animationDuration: 1000, - }; - - // Add heading and pitch for navigation mode when locked - if (location.isMapLocked && location.heading !== null && location.heading !== undefined) { - cameraConfig.heading = location.heading; - cameraConfig.pitch = 45; - } - - cameraRef.current?.setCamera(cameraConfig); - setHasUserMovedMap(false); - - // Track analytics for recenter action - trackEvent('map_recentered', { - timestamp: new Date().toISOString(), - isMapLocked: location.isMapLocked, - zoomLevel: location.isMapLocked ? 16 : 12, - }); - } - }; - - const handlePinPress = (pin: MapMakerInfoData) => { - setSelectedPin(pin); - setIsPinDetailModalOpen(true); - - // Track analytics for pin interaction - trackEvent('map_pin_pressed', { - timestamp: new Date().toISOString(), - pinId: pin.Id, - pinTitle: pin.Title, - pinType: pin.Type, - }); - }; - - const handleSetAsCurrentCall = async (pin: MapMakerInfoData) => { - try { - logger.info({ - message: 'Setting call as current call', - context: { - callId: pin.Id, - callTitle: pin.Title, - }, - }); - - await useCoreStore.getState().setActiveCall(pin.Id); - useToastStore.getState().showToast('success', t('map.call_set_as_current')); - - // Track analytics for setting current call - trackEvent('map_pin_set_as_current_call', { - timestamp: new Date().toISOString(), - pinId: pin.Id, - pinTitle: pin.Title, - pinType: pin.Type, - }); - } catch (error) { - logger.error({ - message: 'Failed to set call as current call', - context: { - error, - callId: pin.Id, - callTitle: pin.Title, - }, - }); - - useToastStore.getState().showToast('error', t('map.failed_to_set_current_call')); - } - }; + void fetchPoisData(); + }, [fetchPoisData]); + + const selectedTab = Array.isArray(tab) ? tab[0] : tab; + const selectedPoiId = useMemo(() => { + const rawPoiId = Array.isArray(poiId) ? poiId[0] : poiId; + const nextPoiId = Number(rawPoiId); + return Number.isFinite(nextPoiId) ? nextPoiId : null; + }, [poiId]); + + const focusedPoi = usePoiStore((state) => (selectedPoiId != null ? state.getPoiById(selectedPoiId) : null)); + const initialTabIndex = selectedTab === 'pois' ? 1 : 0; + + const handlePoiPress = useCallback( + (poi: PoiResultData) => { + router.push(`/poi/${poi.PoiId}`); + }, + [router] + ); - const handleClosePinDetail = () => { - setIsPinDetailModalOpen(false); - setSelectedPin(null); - }; + const handleViewPoiOnMap = useCallback( + (poi: PoiResultData) => { + router.push(`/(app)/map?tab=map&poiId=${poi.PoiId}`); + }, + [router] + ); - // Show recenter button only when map is not locked and user has moved the map - const showRecenterButton = !location.isMapLocked && hasUserMovedMap && location.latitude != null && location.longitude != null; + const tabs: TabItem[] = useMemo( + () => [ + { + key: 'map', + title: t('map.tabs.map'), + content: ( + + + + ), + }, + { + key: 'pois', + title: t('map.tabs.pois'), + content: ( + + + + ), + }, + ], + [focusedPoi, handlePoiPress, handleViewPoiOnMap, t] + ); return ( <> - + {/* Content Area with Side Menu */} - + {/* Permanent Side Menu in Landscape */} {isLandscape && ( - + )} {/* Map Content */} - - setIsMapReady(true)} - testID="home-map-view" - scrollEnabled={!location.isMapLocked} - zoomEnabled={!location.isMapLocked} - rotateEnabled={!location.isMapLocked} - pitchEnabled={!location.isMapLocked} - > - - - {location.latitude != null && location.longitude != null && ( - - - - - - {location.heading !== null && location.heading !== undefined && ( - - )} - - - - )} - - - - {/* Recenter Button - only show when map is not locked and user has moved the map */} - {showRecenterButton && ( - - + + {/* Portrait menu button */} + {!isLandscape && ( + setIsSideMenuOpen(true)} + > + )} + - - {/* Pin Detail Modal */} - {/* Drawer for Portrait Mode */} @@ -380,85 +136,3 @@ export default function HomeMap() { ); } - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - map: { - flex: 1, - }, - markerContainer: { - alignItems: 'center', - justifyContent: 'center', - width: 60, - height: 60, - position: 'relative', - }, - markerOuterRing: { - position: 'absolute', - width: 60, - height: 60, - borderRadius: 30, - backgroundColor: 'rgba(59, 130, 246, 0.15)', - borderWidth: 2, - borderColor: 'rgba(59, 130, 246, 0.3)', - }, - markerInnerContainer: { - width: 24, - height: 24, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#3b82f6', - borderRadius: 12, - borderWidth: 3, - borderColor: '#ffffff', - elevation: 5, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.25, - shadowRadius: 3.84, - }, - markerDot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: '#ffffff', - }, - directionIndicator: { - position: 'absolute', - width: 0, - height: 0, - backgroundColor: 'transparent', - borderStyle: 'solid', - borderLeftWidth: 8, - borderRightWidth: 8, - borderBottomWidth: 24, - borderLeftColor: 'transparent', - borderRightColor: 'transparent', - borderBottomColor: '#3b82f6', - top: -36, - }, - recenterButton: { - position: 'absolute', - bottom: 20, - right: 20, - width: 48, - height: 48, - borderRadius: 24, - backgroundColor: '#3b82f6', - justifyContent: 'center', - alignItems: 'center', - elevation: 5, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.25, - shadowRadius: 3.84, - }, -}); diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index b411b25..5c8c091 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -193,6 +193,7 @@ function RootLayout() { + diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx index 647c1e4..0d4a4e5 100644 --- a/src/app/call/[id]/edit.tsx +++ b/src/app/call/[id]/edit.tsx @@ -6,12 +6,13 @@ import { SHA256 } from 'crypto-js'; import { router, Stack, useLocalSearchParams } from 'expo-router'; import { ChevronDownIcon, PlusIcon, SearchIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { ScrollView, View } from 'react-native'; import * as z from 'zod'; +import { getNewCallData } from '@/api/dispatch'; import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal'; import { Loading } from '@/components/common/loading'; import FullScreenLocationPicker from '@/components/maps/full-screen-location-picker'; @@ -27,6 +28,10 @@ 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 { logger } from '@/lib/logging'; +import { getDestinationPoiIdFromValue, getDestinationPoiSelectOptions, NO_DESTINATION_POI_VALUE } from '@/lib/poi'; +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; +import { type PoiTypeResultData } from '@/models/v4/mapping/poiTypeResultData'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallDetailStore } from '@/stores/calls/detail-store'; import { useCallsStore } from '@/stores/calls/store'; @@ -45,6 +50,7 @@ const formSchema = z.object({ longitude: z.number().optional(), priority: z.string().optional(), type: z.string().optional(), + destinationPoiId: z.string().optional(), contactName: z.string().optional(), contactInfo: z.string().optional(), dispatchSelection: z.object({ @@ -117,6 +123,8 @@ export default function EditCall() { [trackEvent] ); const toast = useToast(); + const toastRef = useRef(toast); + toastRef.current = toast; const [showLocationPicker, setShowLocationPicker] = useState(false); const [showDispatchModal, setShowDispatchModal] = useState(false); const [showAddressSelection, setShowAddressSelection] = useState(false); @@ -124,7 +132,10 @@ export default function EditCall() { const [isGeocodingPlusCode, setIsGeocodingPlusCode] = useState(false); const [isGeocodingCoordinates, setIsGeocodingCoordinates] = useState(false); const [isGeocodingWhat3Words, setIsGeocodingWhat3Words] = useState(false); + const [isDestinationPoisLoading, setIsDestinationPoisLoading] = useState(false); const [addressResults, setAddressResults] = useState([]); + const [destinationPois, setDestinationPois] = useState([]); + const [destinationPoiTypes, setDestinationPoiTypes] = useState([]); const [dispatchSelection, setDispatchSelection] = useState({ everyone: false, users: [], @@ -159,6 +170,7 @@ export default function EditCall() { longitude: undefined, priority: '', type: '', + destinationPoiId: NO_DESTINATION_POI_VALUE, contactName: '', contactInfo: '', dispatchSelection: { @@ -171,6 +183,8 @@ export default function EditCall() { }, }); + const destinationPoiOptions = useMemo(() => getDestinationPoiSelectOptions(destinationPois, destinationPoiTypes), [destinationPois, destinationPoiTypes]); + useEffect(() => { fetchCallPriorities(); fetchCallTypes(); @@ -179,6 +193,51 @@ export default function EditCall() { } }, [fetchCallPriorities, fetchCallTypes, fetchCallDetail, callId]); + useEffect(() => { + const abortController = new AbortController(); + + const loadDestinationPois = async () => { + setIsDestinationPoisLoading(true); + + try { + const response = await getNewCallData(abortController.signal); + + if (abortController.signal.aborted) { + return; + } + + setDestinationPois(response.Data?.DestinationPois || []); + setDestinationPoiTypes(response.Data?.PoiTypes || []); + } catch (error) { + if (abortController.signal.aborted) { + return; + } + + logger.error({ message: 'Error loading call destination POIs', context: { error } }); + toastRef.current.show({ + placement: 'top', + render: () => { + return ( + + {t('calls.destination_load_error')} + + ); + }, + }); + } finally { + if (!abortController.signal.aborted) { + setIsDestinationPoisLoading(false); + } + } + }; + + void loadDestinationPois(); + + return () => { + abortController.abort(); + }; + }, [t]); + // Analytics: Track when edit call page is viewed useFocusEffect( useCallback(() => { @@ -265,6 +324,7 @@ export default function EditCall() { longitude: call.Longitude ? parseFloat(call.Longitude) : undefined, priority: priority?.Name || '', type: type?.Name || '', + destinationPoiId: call.DestinationPoiId ? call.DestinationPoiId.toString() : NO_DESTINATION_POI_VALUE, contactName: call.ContactName || '', contactInfo: call.ContactInfo || '', dispatchSelection: initialDispatchSelection, @@ -292,6 +352,8 @@ export default function EditCall() { console.log('onSubmit called'); } try { + const destinationPoiId = getDestinationPoiIdFromValue(data.destinationPoiId); + // If we have latitude and longitude, add them to the data if (selectedLocation?.latitude != null && selectedLocation?.longitude != null) { data.latitude = selectedLocation.latitude; @@ -330,6 +392,7 @@ export default function EditCall() { hasPlusCode: !!data.plusCode, hasContactName: !!data.contactName, hasContactInfo: !!data.contactInfo, + hasDestinationPoi: destinationPoiId != null, dispatchEveryone: data.dispatchSelection?.everyone || false, dispatchCount: (data.dispatchSelection?.users.length || 0) + (data.dispatchSelection?.groups.length || 0) + (data.dispatchSelection?.roles.length || 0) + (data.dispatchSelection?.units.length || 0), }); @@ -343,6 +406,7 @@ export default function EditCall() { type: type?.Id || '', note: data.note || '', address: data.address || '', + destinationPoiId: destinationPoiId, ...(data.latitude != null && { latitude: data.latitude }), ...(data.longitude != null && { longitude: data.longitude }), what3words: data.what3words || '', @@ -369,6 +433,7 @@ export default function EditCall() { priority: data.priority || '', type: data.type || '', hasLocation: !!(data.latitude && data.longitude), + hasDestinationPoi: destinationPoiId != null, dispatchMethod: data.dispatchSelection?.everyone ? 'everyone' : 'selective', }); @@ -767,6 +832,39 @@ export default function EditCall() { + + + + {t('calls.destination')} + + { + const selectedDestinationLabel = value === NO_DESTINATION_POI_VALUE ? t('common.none') : destinationPoiOptions.find((option) => option.value === value)?.label; + + return ( + + ); + }} + /> + + + diff --git a/src/app/call/[id]/index.tsx b/src/app/call/[id]/index.tsx index 1adb75a..178bf9b 100644 --- a/src/app/call/[id]/index.tsx +++ b/src/app/call/[id]/index.tsx @@ -63,6 +63,12 @@ export default function CallDetail() { const { colorScheme } = useColorScheme(); const textColor = colorScheme === 'dark' ? '#FFFFFF' : '#000000'; + const destinationName = call?.DestinationName?.trim() ?? ''; + const destinationAddress = call?.DestinationAddress?.trim() ?? ''; + const destinationTypeName = call?.DestinationTypeName?.trim() ?? ''; + const destinationDisplayName = destinationName || destinationAddress || destinationTypeName; + const hasDestination = destinationDisplayName.length > 0; + const hasDestinationCoordinates = isValidCoordinates(call?.DestinationLatitude ?? undefined, call?.DestinationLongitude ?? undefined); // Get current user location from the location store const userLocation = useLocationStore((state) => ({ @@ -210,7 +216,7 @@ export default function CallDetail() { /** * Validates if coordinates are valid for routing */ - const isValidCoordinates = (lat: number | null | undefined, lng: number | null | undefined): boolean => { + function isValidCoordinates(lat: number | null | undefined, lng: number | null | undefined): boolean { // Check if coordinates exist and are valid numbers if (lat === null || lat === undefined || lng === null || lng === undefined) { return false; @@ -227,7 +233,7 @@ export default function CallDetail() { } return true; - }; + } /** * Opens the device's native maps application with directions to the call location @@ -295,6 +301,45 @@ export default function CallDetail() { } }; + const handleDestinationRoute = async () => { + if (!call || !hasDestinationCoordinates || call.DestinationLatitude == null || call.DestinationLongitude == null) { + showToast('error', t('call_detail.no_location_for_routing')); + return; + } + + try { + trackEvent('call_destination_route_opened', { + timestamp: new Date().toISOString(), + callId: call.CallId, + destinationTypeName: destinationTypeName || 'POI', + hasUserLocation: !!(userLocation.latitude && userLocation.longitude), + }); + + const success = await openMapsWithDirections( + call.DestinationLatitude, + call.DestinationLongitude, + destinationDisplayName || t('call_detail.destination'), + userLocation.latitude ?? undefined, + userLocation.longitude ?? undefined + ); + + if (!success) { + showToast('error', t('call_detail.failed_to_open_maps')); + } + } catch (error) { + logger.error({ + message: 'Failed to open maps for call destination routing', + context: { + error, + callId, + destinationLatitude: call.DestinationLatitude, + destinationLongitude: call.DestinationLongitude, + }, + }); + showToast('error', t('call_detail.failed_to_open_maps')); + } + }; + if (isLoading) { return ( <> @@ -398,6 +443,22 @@ export default function CallDetail() { {t('call_detail.address')} {call.Address} + {hasDestination ? ( + + {t('call_detail.destination')} + + {destinationDisplayName} + {destinationAddress && destinationAddress !== destinationDisplayName ? {destinationAddress} : null} + {destinationTypeName ? {destinationTypeName} : null} + {hasDestinationCoordinates ? ( + + ) : null} + + + ) : null} {t('call_detail.note')} diff --git a/src/app/call/new/index.tsx b/src/app/call/new/index.tsx index 7b126ed..55ee37f 100644 --- a/src/app/call/new/index.tsx +++ b/src/app/call/new/index.tsx @@ -1,12 +1,11 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useFocusEffect } from '@react-navigation/native'; -import { render } from '@testing-library/react-native'; import axios from 'axios'; import * as Location from 'expo-location'; import { router, Stack } from 'expo-router'; import { ChevronDownIcon, PlusIcon, SearchIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { Platform, ScrollView, View } from 'react-native'; @@ -14,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'; import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal'; import { Loading } from '@/components/common/loading'; import FullScreenLocationPicker from '@/components/maps/full-screen-location-picker'; @@ -29,6 +29,10 @@ 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 { logger } from '@/lib/logging'; +import { getDestinationPoiIdFromValue, getDestinationPoiSelectOptions, NO_DESTINATION_POI_VALUE } from '@/lib/poi'; +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; +import { type PoiTypeResultData } from '@/models/v4/mapping/poiTypeResultData'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; import { type DispatchSelection } from '@/stores/dispatch/store'; @@ -60,6 +64,7 @@ const formSchema = z.object({ longitude: z.number().optional(), priority: z.string().min(1, { message: 'Priority is required' }), type: z.string().min(1, { message: 'Type is required' }), + destinationPoiId: z.string().optional(), contactName: z.string().optional(), contactInfo: z.string().optional(), dispatchSelection: z @@ -122,6 +127,8 @@ export default function NewCall() { const { config } = useCoreStore(); const { trackEvent } = useAnalytics(); const toast = useToast(); + const toastRef = useRef(toast); + toastRef.current = toast; const insets = useSafeAreaInsets(); const [showLocationPicker, setShowLocationPicker] = useState(false); const [showDispatchModal, setShowDispatchModal] = useState(false); @@ -130,7 +137,10 @@ export default function NewCall() { const [isGeocodingPlusCode, setIsGeocodingPlusCode] = useState(false); const [isGeocodingCoordinates, setIsGeocodingCoordinates] = useState(false); const [isGeocodingWhat3Words, setIsGeocodingWhat3Words] = useState(false); + const [isDestinationPoisLoading, setIsDestinationPoisLoading] = useState(false); const [addressResults, setAddressResults] = useState([]); + const [destinationPois, setDestinationPois] = useState([]); + const [destinationPoiTypes, setDestinationPoiTypes] = useState([]); const [dispatchSelection, setDispatchSelection] = useState({ everyone: false, users: [], @@ -163,6 +173,7 @@ export default function NewCall() { longitude: undefined, priority: '', type: '', + destinationPoiId: NO_DESTINATION_POI_VALUE, contactName: '', contactInfo: '', dispatchSelection: { @@ -175,11 +186,49 @@ export default function NewCall() { }, }); + const destinationPoiOptions = useMemo(() => getDestinationPoiSelectOptions(destinationPois, destinationPoiTypes), [destinationPois, destinationPoiTypes]); + useEffect(() => { fetchCallPriorities(); fetchCallTypes(); }, [fetchCallPriorities, fetchCallTypes]); + useEffect(() => { + const abortController = new AbortController(); + + const loadDestinationPois = async () => { + setIsDestinationPoisLoading(true); + + try { + const response = await getNewCallData(abortController.signal); + + if (abortController.signal.aborted) { + return; + } + + setDestinationPois(response.Data?.DestinationPois || []); + setDestinationPoiTypes(response.Data?.PoiTypes || []); + } catch (error) { + if (abortController.signal.aborted) { + return; + } + + logger.error({ message: 'Error loading call destination POIs', context: { error } }); + toastRef.current.error(t('calls.destination_load_error')); + } finally { + if (!abortController.signal.aborted) { + setIsDestinationPoisLoading(false); + } + } + }; + + void loadDestinationPois(); + + return () => { + abortController.abort(); + }; + }, [t]); + // Analytics: Track when the new call page is viewed useFocusEffect( useCallback(() => { @@ -195,6 +244,8 @@ export default function NewCall() { const onSubmit = async (data: FormValues) => { try { + const destinationPoiId = getDestinationPoiIdFromValue(data.destinationPoiId); + // Analytics: Track call creation attempt trackEvent('call_create_attempted', { timestamp: new Date().toISOString(), @@ -207,6 +258,7 @@ export default function NewCall() { hasPlusCode: !!data.plusCode, hasContactName: !!data.contactName, hasContactInfo: !!data.contactInfo, + hasDestinationPoi: destinationPoiId != null, dispatchEveryone: data.dispatchSelection?.everyone || false, dispatchCount: (data.dispatchSelection?.users.length || 0) + (data.dispatchSelection?.groups.length || 0) + (data.dispatchSelection?.roles.length || 0) + (data.dispatchSelection?.units.length || 0), }); @@ -227,6 +279,7 @@ export default function NewCall() { type: type?.Id || '', note: data.note || '', address: data.address || '', + destinationPoiId: destinationPoiId, latitude: data.latitude || 0, longitude: data.longitude || 0, what3words: data.what3words || '', @@ -245,6 +298,7 @@ export default function NewCall() { priority: data.priority, type: data.type, hasLocation: Number.isFinite(data.latitude) && Number.isFinite(data.longitude), + hasDestinationPoi: destinationPoiId != null, dispatchMethod: data.dispatchSelection?.everyone ? 'everyone' : 'selective', }); @@ -881,6 +935,39 @@ export default function NewCall() { + + + + {t('calls.destination')} + + { + const selectedDestinationLabel = value === NO_DESTINATION_POI_VALUE ? t('common.none') : destinationPoiOptions.find((option) => option.value === value)?.label; + + return ( + + ); + }} + /> + + + diff --git a/src/app/poi/[id].tsx b/src/app/poi/[id].tsx new file mode 100644 index 0000000..8c71d94 --- /dev/null +++ b/src/app/poi/[id].tsx @@ -0,0 +1,250 @@ +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; +import { ArrowLeftIcon, MapIcon, RouteIcon } from 'lucide-react-native'; +import React, { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, ScrollView, View } from 'react-native'; + +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import StaticMap from '@/components/maps/static-map'; +import { FocusAwareStatusBar, SafeAreaView } from '@/components/ui'; +import { Badge } from '@/components/ui/badge'; +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 { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { useAnalytics } from '@/hooks/use-analytics'; +import { openMapsWithDirections } from '@/lib/navigation'; +import { getPoiDisplayName } from '@/lib/poi'; +import { useLocationStore } from '@/stores/app/location-store'; +import { usePoiStore } from '@/stores/poi/store'; +import { usePersonnelStatusBottomSheetStore } from '@/stores/status/personnel-status-store'; +import { useToastStore } from '@/stores/toast/store'; + +const parsePoiId = (id: string | string[] | undefined) => { + const rawValue = Array.isArray(id) ? id[0] : id; + const nextPoiId = Number(rawValue); + return Number.isInteger(nextPoiId) && nextPoiId > 0 ? nextPoiId : null; +}; + +export default function PoiDetail() { + const { t } = useTranslation(); + const router = useRouter(); + const { trackEvent } = useAnalytics(); + const { id } = useLocalSearchParams(); + const poiId = parsePoiId(id); + const poi = usePoiStore((state) => (poiId != null ? state.getPoiById(poiId) : null)); + const fetchPoiDetail = usePoiStore((state) => state.fetchPoiDetail); + const isLoadingPoi = usePoiStore((state) => state.isLoadingPoi); + const error = usePoiStore((state) => state.error); + const showToast = useToastStore((state) => state.showToast); + const userLocation = useLocationStore((state) => ({ + latitude: state.latitude, + longitude: state.longitude, + })); + + useEffect(() => { + if (poiId != null) { + void fetchPoiDetail(poiId); + } + }, [fetchPoiDetail, poiId]); + + useEffect(() => { + if (poi) { + trackEvent('poi_detail_viewed', { + timestamp: new Date().toISOString(), + poiId: poi.PoiId, + poiTypeId: poi.PoiTypeId, + isDestination: poi.IsDestination, + }); + } + }, [poi, trackEvent]); + + const handleBack = useCallback(() => { + router.back(); + }, [router]); + + const handleOpenMaps = useCallback(async () => { + if (!poi) { + return; + } + + const success = await openMapsWithDirections(poi.Latitude, poi.Longitude, getPoiDisplayName(poi), userLocation.latitude ?? undefined, userLocation.longitude ?? undefined); + + if (!success) { + showToast('error', t('poi.route_error')); + } + }, [poi, showToast, t, userLocation.latitude, userLocation.longitude]); + + const handleViewOnMap = useCallback(() => { + if (!poi) { + return; + } + + router.push(`/(app)/map?tab=map&poiId=${poi.PoiId}`); + }, [poi, router]); + + const handleSetStatusDestination = useCallback(() => { + if (!poi || !poi.IsDestination) { + return; + } + + usePersonnelStatusBottomSheetStore.getState().setIsOpen(true, undefined, { preselectedPoi: poi }); + router.push('/(app)/home'); + }, [poi, router]); + + const title = poi ? getPoiDisplayName(poi) : t('poi.title'); + + if (poiId == null) { + return ( + <> + ( + + + + ), + }} + /> + + + + ); + } + + if (isLoadingPoi && !poi) { + return ( + <> + ( + + + + ), + }} + /> + + + + ); + } + + if (!poi) { + return ( + <> + ( + + + + ), + }} + /> + + + + ); + } + + return ( + <> + ( + + + + ), + }} + /> + + + + ); +} diff --git a/src/components/home/__tests__/status-buttons.test.tsx b/src/components/home/__tests__/status-buttons.test.tsx new file mode 100644 index 0000000..cf8082e --- /dev/null +++ b/src/components/home/__tests__/status-buttons.test.tsx @@ -0,0 +1,159 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { StatusButtons } from '../status-buttons'; +import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useHomeStore } from '@/stores/home/home-store'; +import { usePersonnelStatusBottomSheetStore } from '@/stores/status/personnel-status-store'; + +jest.mock('@gluestack-ui/nativewind-utils/tva', () => ({ + tva: jest.fn().mockImplementation(() => { + return jest.fn().mockImplementation((props) => { + const { class: className } = props || {}; + return className || ''; + }); + }), +})); + +jest.mock('@gluestack-ui/nativewind-utils/IsWeb', () => ({ + isWeb: false, +})); + +jest.mock('@gluestack-ui/nativewind-utils', () => ({ + tva: jest.fn().mockImplementation(() => { + return jest.fn().mockImplementation((props) => { + const { class: className } = props || {}; + return className || ''; + }); + }), + isWeb: false, +})); + +jest.mock('@/components/common/loading', () => ({ + Loading: () => null, +})); + +jest.mock('@/stores/home/home-store'); +jest.mock('@/stores/app/core-store'); +jest.mock('@/stores/status/personnel-status-store'); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'home.status.no_options_available': 'No status options available', + }; + + return translations[key] || key; + }, + }), +})); + +const mockUseHomeStore = useHomeStore as jest.MockedFunction; +const mockUseCoreStore = useCoreStore as jest.MockedFunction; +const mockUsePersonnelStatusBottomSheetStore = usePersonnelStatusBottomSheetStore as jest.MockedFunction; + +const createStatus = (overrides: Partial): StatusesResultData => ({ + Id: 0, + Type: 0, + StateId: 0, + Text: '', + BColor: '#2563eb', + Color: '#ffffff', + Gps: false, + Note: 0, + Detail: 0, + ...overrides, +}); + +describe('StatusButtons', () => { + const mockSetIsOpen = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseHomeStore.mockReturnValue({ + departmentStats: { + openCalls: 0, + personnelInService: 0, + unitsInService: 0, + }, + isLoadingStats: false, + currentUser: null, + currentUserStatus: null, + currentUserStaffing: null, + isLoadingUser: false, + availableStatuses: [], + availableStaffings: [], + isLoadingOptions: false, + error: null, + fetchDepartmentStats: jest.fn(), + fetchCurrentUserInfo: jest.fn(), + refreshAll: jest.fn(), + }); + + mockUsePersonnelStatusBottomSheetStore.mockReturnValue({ + setIsOpen: mockSetIsOpen, + } as ReturnType); + }); + + it('filters out the legacy hidden system statuses by ID', () => { + mockUseCoreStore.mockReturnValue({ + activeStatuses: [ + createStatus({ Id: 1, Text: 'Available', Detail: 0 }), + createStatus({ Id: 4, Text: 'System Status 4', Detail: 0 }), + createStatus({ Id: 5, Text: 'System Status 5', Detail: 5 }), + createStatus({ Id: 10, Text: 'Transporting', Detail: 4 }), + ], + } as ReturnType); + + render(); + + expect(screen.getByText('Available')).toBeTruthy(); + expect(screen.getByText('Transporting')).toBeTruthy(); + expect(screen.queryByText('System Status 4')).toBeNull(); + expect(screen.queryByText('System Status 5')).toBeNull(); + }); + + it('does not hide non-legacy statuses just because they use newer detail values', () => { + mockUseCoreStore.mockReturnValue({ + activeStatuses: [ + createStatus({ Id: 8, Text: 'POI Destination Status', Detail: 4 }), + createStatus({ Id: 9, Text: 'Call and POI Status', Detail: 5 }), + ], + } as ReturnType); + + render(); + + expect(screen.getByText('POI Destination Status')).toBeTruthy(); + expect(screen.getByText('Call and POI Status')).toBeTruthy(); + }); + + it('opens the personnel status sheet with the selected visible status', () => { + const availableStatus = createStatus({ Id: 1, Text: 'Available', Detail: 0 }); + + mockUseCoreStore.mockReturnValue({ + activeStatuses: [availableStatus], + } as ReturnType); + + render(); + + fireEvent.press(screen.getByTestId('status-button-1')); + + expect(mockSetIsOpen).toHaveBeenCalledWith(true, availableStatus); + }); + + it('shows the empty state when every status is filtered out', () => { + mockUseCoreStore.mockReturnValue({ + activeStatuses: [ + createStatus({ Id: 4, Text: 'System Status 4', Detail: 0 }), + createStatus({ Id: 5, Text: 'System Status 5', Detail: 5 }), + ], + } as ReturnType); + + render(); + + expect(screen.getByText('No status options available')).toBeTruthy(); + }); +}); diff --git a/src/components/home/status-buttons.tsx b/src/components/home/status-buttons.tsx index f4b0c4c..fb10899 100644 --- a/src/components/home/status-buttons.tsx +++ b/src/components/home/status-buttons.tsx @@ -11,6 +11,8 @@ import { useCoreStore } from '@/stores/app/core-store'; import { useHomeStore } from '@/stores/home/home-store'; import { usePersonnelStatusBottomSheetStore } from '@/stores/status/personnel-status-store'; +const LEGACY_HIDDEN_STATUS_IDS = [4, 5, 6, 7]; + export const StatusButtons: React.FC = () => { const { t } = useTranslation(); const { isLoadingOptions } = useHomeStore(); @@ -26,7 +28,10 @@ export const StatusButtons: React.FC = () => { return ; } - const visibleStatuses = activeStatuses.filter((status) => ![4, 5, 6, 7].includes(status.Id)); + // These IDs are legacy system-managed statuses that Resgrid sets internally. + // They predate the newer Detail-based destination model and should stay hidden + // from the Home tab buttons even though they may still be applied under the hood. + const visibleStatuses = activeStatuses.filter((status) => !LEGACY_HIDDEN_STATUS_IDS.includes(status.Id)); if (visibleStatuses.length === 0) { return ( diff --git a/src/components/home/user-status-card.tsx b/src/components/home/user-status-card.tsx index ca8af02..534dc99 100644 --- a/src/components/home/user-status-card.tsx +++ b/src/components/home/user-status-card.tsx @@ -11,7 +11,7 @@ import { useHomeStore } from '@/stores/home/home-store'; export const UserStatusCard: React.FC = () => { const { t } = useTranslation(); - const { currentUser, isLoadingUser } = useHomeStore(); + const { currentUser, currentUserStatus, isLoadingUser } = useHomeStore(); if (isLoadingUser) { return ( @@ -22,6 +22,7 @@ export const UserStatusCard: React.FC = () => { } const displayStatus = currentUser?.Status || t('home.user.status_unknown'); + const destinationText = currentUserStatus?.DestinationName || currentUserStatus?.DestinationAddress || currentUser?.StatusDestinationName || ''; let displayColor = currentUser?.StatusColor || '#6B7280'; // Default gray // Fix up the color values to match the design system @@ -59,6 +60,11 @@ export const UserStatusCard: React.FC = () => { {displayStatus} + {destinationText ? ( + + {t('call_detail.destination')}: {destinationText} + + ) : null} {currentUser?.StatusTimestamp && ( {t('home.user.updated')}: {new Date(currentUser.StatusTimestamp).toLocaleTimeString()} diff --git a/src/components/maps/map-panel.tsx b/src/components/maps/map-panel.tsx new file mode 100644 index 0000000..8602e60 --- /dev/null +++ b/src/components/maps/map-panel.tsx @@ -0,0 +1,459 @@ +import Mapbox from '@rnmapbox/maps'; +import { useFocusEffect, useRouter } from 'expo-router'; +import { NavigationIcon } from 'lucide-react-native'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Animated, StyleSheet, TouchableOpacity, View } from 'react-native'; + +import { getMapDataAndMarkers } from '@/api/mapping/mapping'; +import { Loading } from '@/components/common/loading'; +import { useAnalytics } from '@/hooks/use-analytics'; +import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates'; +import { logger } from '@/lib/logging'; +import { onSortOptions } from '@/lib/utils'; +import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useLocationStore } from '@/stores/app/location-store'; +import { useToastStore } from '@/stores/toast/store'; + +import MapPins from './map-pins'; +import PinDetailModal from './pin-detail-modal'; + +const POI_MARKER_TYPE = 4; + +interface MapPanelProps { + focusedPoi: PoiResultData | null; +} + +export const MapPanel: React.FC = ({ focusedPoi }) => { + const { t } = useTranslation(); + const router = useRouter(); + const { trackEvent } = useAnalytics(); + const mapRef = useRef(null); + const cameraRef = useRef(null); + const lastFocusedPoiId = useRef(null); + const [isMapReady, setIsMapReady] = useState(false); + const [hasUserMovedMap, setHasUserMovedMap] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [mapPins, setMapPins] = useState([]); + const [selectedPin, setSelectedPin] = useState(null); + const [isPinDetailModalOpen, setIsPinDetailModalOpen] = useState(false); + const location = useLocationStore((state) => ({ + latitude: state.latitude, + longitude: state.longitude, + heading: state.heading, + isMapLocked: state.isMapLocked, + })); + + const mapOptions = useMemo(() => { + return Object.keys(Mapbox.StyleURL) + .map((key) => ({ + label: key, + data: (Mapbox.StyleURL as Record)[key], + })) + .sort(onSortOptions); + }, []); + + const styleURL = mapOptions[0]?.data; + const pulseAnim = useRef(new Animated.Value(1)).current; + const isFollowingUser = location.isMapLocked && focusedPoi == null; + const isInteractionLocked = location.isMapLocked && focusedPoi == null; + const showRecenterButton = !isFollowingUser && hasUserMovedMap && location.latitude != null && location.longitude != null; + + useMapSignalRUpdates(setMapPins); + + useFocusEffect( + useCallback(() => { + trackEvent('map_viewed', { + timestamp: new Date().toISOString(), + isMapLocked: location.isMapLocked, + hasLocation: location.latitude != null && location.longitude != null, + }); + + if (focusedPoi == null) { + setHasUserMovedMap(false); + } + + if (focusedPoi == null && isMapReady && location.latitude != null && location.longitude != null) { + const cameraConfig: { + centerCoordinate: [number, number]; + zoomLevel: number; + animationDuration: number; + heading: number; + pitch: number; + } = { + centerCoordinate: [location.longitude, location.latitude], + zoomLevel: location.isMapLocked ? 16 : 12, + animationDuration: 1000, + heading: 0, + pitch: 0, + }; + + if (location.isMapLocked && location.heading != null) { + cameraConfig.heading = location.heading; + cameraConfig.pitch = 45; + } + + cameraRef.current?.setCamera(cameraConfig); + } + }, [focusedPoi, isMapReady, location.heading, location.isMapLocked, location.latitude, location.longitude, trackEvent]) + ); + + useEffect(() => { + if (focusedPoi != null && isMapReady && lastFocusedPoiId.current !== focusedPoi.PoiId) { + cameraRef.current?.setCamera({ + centerCoordinate: [focusedPoi.Longitude, focusedPoi.Latitude], + zoomLevel: 15, + animationDuration: 1000, + }); + setHasUserMovedMap(true); + lastFocusedPoiId.current = focusedPoi.PoiId; + + trackEvent('map_poi_focused', { + timestamp: new Date().toISOString(), + poiId: focusedPoi.PoiId, + poiTypeId: focusedPoi.PoiTypeId, + }); + } else if (focusedPoi == null) { + lastFocusedPoiId.current = null; + } + }, [focusedPoi, isMapReady, trackEvent]); + + useEffect(() => { + if (focusedPoi != null) { + return; + } + + if (isMapReady && location.latitude != null && location.longitude != null) { + if (isFollowingUser || !hasUserMovedMap) { + const cameraConfig: { + centerCoordinate: [number, number]; + zoomLevel: number; + animationDuration: number; + heading?: number; + pitch?: number; + } = { + centerCoordinate: [location.longitude, location.latitude], + zoomLevel: location.isMapLocked ? 16 : 12, + animationDuration: location.isMapLocked ? 500 : 1000, + }; + + if (location.isMapLocked && location.heading != null) { + cameraConfig.heading = location.heading; + cameraConfig.pitch = 45; + } + + cameraRef.current?.setCamera(cameraConfig); + } + } + }, [focusedPoi, hasUserMovedMap, isFollowingUser, isMapReady, location.heading, location.isMapLocked, location.latitude, location.longitude]); + + useEffect(() => { + if (focusedPoi != null) { + return; + } + + if (location.isMapLocked) { + setHasUserMovedMap(false); + return; + } + + setHasUserMovedMap(false); + + if (isMapReady && location.latitude != null && location.longitude != null) { + cameraRef.current?.setCamera({ + centerCoordinate: [location.longitude, location.latitude], + zoomLevel: 12, + heading: 0, + pitch: 0, + animationDuration: 1000, + }); + } + }, [focusedPoi, isMapReady, location.isMapLocked, location.latitude, location.longitude]); + + useEffect(() => { + let isMounted = true; + + const fetchMapDataAndMarkers = async () => { + try { + const mapDataAndMarkers = await getMapDataAndMarkers(); + + if (isMounted && mapDataAndMarkers?.Data) { + setMapPins(mapDataAndMarkers.Data.MapMakerInfos); + } + } catch (error) { + logger.error({ + message: 'Failed to fetch initial map markers', + context: { error }, + }); + } finally { + if (isMounted) { + setIsLoading(false); + } + } + }; + + void fetchMapDataAndMarkers(); + + return () => { + isMounted = false; + }; + }, []); + + useEffect(() => { + Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1.2, + duration: 1000, + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 1, + duration: 1000, + useNativeDriver: true, + }), + ]) + ).start(); + }, [pulseAnim]); + + const onCameraChanged = useCallback( + (event: any) => { + if (event.properties?.isUserInteraction && !isInteractionLocked) { + setHasUserMovedMap(true); + } + }, + [isInteractionLocked] + ); + + const handleRecenterMap = useCallback(() => { + if (location.latitude != null && location.longitude != null) { + const cameraConfig: { + centerCoordinate: [number, number]; + zoomLevel: number; + animationDuration: number; + heading?: number; + pitch?: number; + } = { + centerCoordinate: [location.longitude, location.latitude], + zoomLevel: location.isMapLocked ? 16 : 12, + animationDuration: 1000, + }; + + if (location.isMapLocked && location.heading != null) { + cameraConfig.heading = location.heading; + cameraConfig.pitch = 45; + } + + cameraRef.current?.setCamera(cameraConfig); + setHasUserMovedMap(false); + + trackEvent('map_recentered', { + timestamp: new Date().toISOString(), + isMapLocked: location.isMapLocked, + zoomLevel: location.isMapLocked ? 16 : 12, + }); + } + }, [location.heading, location.isMapLocked, location.latitude, location.longitude, trackEvent]); + + const handlePinPress = useCallback( + (pin: MapMakerInfoData) => { + trackEvent('map_pin_pressed', { + timestamp: new Date().toISOString(), + pinId: pin.Id, + pinTitle: pin.Title, + pinType: pin.Type, + }); + + const isPoiPin = pin.Type === POI_MARKER_TYPE || pin.PoiTypeId != null; + + if (isPoiPin) { + router.push(`/poi/${pin.Id}`); + return; + } + + setSelectedPin(pin); + setIsPinDetailModalOpen(true); + }, + [router, trackEvent] + ); + + const handleSetAsCurrentCall = useCallback( + async (pin: MapMakerInfoData) => { + try { + await useCoreStore.getState().setActiveCall(pin.Id); + useToastStore.getState().showToast('success', t('map.call_set_as_current')); + + trackEvent('map_pin_set_as_current_call', { + timestamp: new Date().toISOString(), + pinId: pin.Id, + pinTitle: pin.Title, + pinType: pin.Type, + }); + } catch (error) { + logger.error({ + message: 'Failed to set call as current call', + context: { + error, + callId: pin.Id, + callTitle: pin.Title, + }, + }); + + useToastStore.getState().showToast('error', t('map.failed_to_set_current_call')); + } + }, + [t, trackEvent] + ); + + const handleClosePinDetail = useCallback(() => { + setIsPinDetailModalOpen(false); + setSelectedPin(null); + }, []); + + return ( + + setIsMapReady(true)} + testID="home-map-view" + scrollEnabled={!isInteractionLocked} + zoomEnabled={!isInteractionLocked} + rotateEnabled={!isInteractionLocked} + pitchEnabled={!isInteractionLocked} + > + + + {location.latitude != null && location.longitude != null ? ( + + + + + + {location.heading != null ? ( + + ) : null} + + + + ) : null} + + + + {showRecenterButton ? ( + + + + ) : null} + + {isLoading ? : null} + + + + ); +}; + +const styles = StyleSheet.create({ + map: { + flex: 1, + }, + markerContainer: { + alignItems: 'center', + justifyContent: 'center', + width: 60, + height: 60, + position: 'relative', + }, + markerOuterRing: { + position: 'absolute', + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: 'rgba(59, 130, 246, 0.15)', + borderWidth: 2, + borderColor: 'rgba(59, 130, 246, 0.3)', + }, + markerInnerContainer: { + width: 24, + height: 24, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#3b82f6', + borderRadius: 12, + borderWidth: 3, + borderColor: '#ffffff', + elevation: 5, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + }, + markerDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '#ffffff', + }, + directionIndicator: { + position: 'absolute', + width: 0, + height: 0, + backgroundColor: 'transparent', + borderStyle: 'solid', + borderLeftWidth: 8, + borderRightWidth: 8, + borderBottomWidth: 24, + borderLeftColor: 'transparent', + borderRightColor: 'transparent', + borderBottomColor: '#3b82f6', + top: -36, + }, + recenterButton: { + position: 'absolute', + bottom: 20, + right: 20, + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: '#3b82f6', + justifyContent: 'center', + alignItems: 'center', + elevation: 5, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + }, +}); + +export default MapPanel; diff --git a/src/components/maps/map-pins.tsx b/src/components/maps/map-pins.tsx index 719f820..a8a2787 100644 --- a/src/components/maps/map-pins.tsx +++ b/src/components/maps/map-pins.tsx @@ -1,14 +1,11 @@ import Mapbox from '@rnmapbox/maps'; import React from 'react'; -import { type MAP_ICONS } from '@/constants/map-icons'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { useSecurityStore } from '@/stores/security/store'; import PinMarker from './pin-marker'; -type MapIconKey = keyof typeof MAP_ICONS; - interface MapPinsProps { pins: MapMakerInfoData[]; onPinPress?: (pin: MapMakerInfoData) => void; @@ -34,7 +31,7 @@ const MapPins: React.FC = ({ pins, onPinPress }) => { <> {filteredPins.map((pin) => ( - onPinPress?.(pin)} /> + onPinPress?.(pin)} /> ))} diff --git a/src/components/maps/pin-marker.tsx b/src/components/maps/pin-marker.tsx index 17a05b5..1a528cd 100644 --- a/src/components/maps/pin-marker.tsx +++ b/src/components/maps/pin-marker.tsx @@ -3,22 +3,22 @@ import { useColorScheme } from 'nativewind'; import React from 'react'; import { Image, StyleSheet, Text, TouchableOpacity } from 'react-native'; -import { MAP_ICONS } from '@/constants/map-icons'; - -type MapIconKey = keyof typeof MAP_ICONS; +import { MAP_ICONS, type MapIconKey, resolveMapIconKey } from '@/constants/map-icons'; interface PinMarkerProps { - imagePath?: MapIconKey; + imagePath?: string | null; + marker?: string | null; title: string; size?: number; markerRef?: Mapbox.PointAnnotation | null; onPress?: () => void; + fallbackIconKey?: MapIconKey; } -const PinMarker: React.FC = ({ imagePath, title, size = 32, onPress }) => { +const PinMarker: React.FC = ({ imagePath, marker, title, size = 32, onPress, fallbackIconKey = 'call' }) => { const { colorScheme } = useColorScheme(); - const icon = imagePath ? MAP_ICONS[imagePath.toLowerCase() as MapIconKey] : MAP_ICONS['call']; + const icon = MAP_ICONS[resolveMapIconKey({ imagePath, marker, fallback: fallbackIconKey })]; return ( diff --git a/src/components/maps/poi-list-panel.tsx b/src/components/maps/poi-list-panel.tsx new file mode 100644 index 0000000..d6c9cd1 --- /dev/null +++ b/src/components/maps/poi-list-panel.tsx @@ -0,0 +1,200 @@ +import { FlashList, type ListRenderItem } from '@shopify/flash-list'; +import { ChevronDownIcon, MapIcon } from 'lucide-react-native'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { Badge } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Select, SelectBackdrop, SelectContent, SelectDragIndicator, SelectDragIndicatorWrapper, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { getPoiDisplayName } from '@/lib/poi'; +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; +import { usePoiStore } from '@/stores/poi/store'; + +type PoiSortOption = 'name' | 'type'; + +interface PoiListPanelProps { + onPoiPress: (poi: PoiResultData) => void; + onViewOnMap: (poi: PoiResultData) => void; +} + +interface PoiListItemProps { + poi: PoiResultData; + onPoiPress: (poi: PoiResultData) => void; + onViewOnMap: (poi: PoiResultData) => void; +} + +const ALL_POI_TYPES_VALUE = 'all'; + +const PoiListItem: React.FC = React.memo(({ poi, onPoiPress, onViewOnMap }) => { + const { t } = useTranslation(); + + const handleViewDetails = useCallback(() => { + onPoiPress(poi); + }, [onPoiPress, poi]); + + const handleViewOnMap = useCallback(() => { + onViewOnMap(poi); + }, [onViewOnMap, poi]); + + const displayName = getPoiDisplayName(poi); + + return ( + + + + + {displayName} + {poi.PoiTypeName} + + {poi.IsDestination ? ( + + {t('poi.destination_enabled')} + + ) : null} + + + {poi.Address ? {poi.Address} : null} + {poi.Note ? {poi.Note} : null} + + + + + + + + ); +}); + +PoiListItem.displayName = 'PoiListItem'; + +export const PoiListPanel: React.FC = ({ onPoiPress, onViewOnMap }) => { + const { t } = useTranslation(); + const { poiTypes, pois, isLoading, error } = usePoiStore((state) => ({ + poiTypes: state.poiTypes, + pois: state.pois, + isLoading: state.isLoading, + error: state.error, + })); + const [selectedPoiType, setSelectedPoiType] = useState(ALL_POI_TYPES_VALUE); + const [sortBy, setSortBy] = useState('name'); + + const filteredPois = useMemo(() => { + const nextPois = selectedPoiType === ALL_POI_TYPES_VALUE ? [...pois] : pois.filter((poi) => poi.PoiTypeId.toString() === selectedPoiType); + + nextPois.sort((left, right) => { + if (sortBy === 'type') { + const typeComparison = (left.PoiTypeName || '').localeCompare(right.PoiTypeName || ''); + return typeComparison !== 0 ? typeComparison : getPoiDisplayName(left).localeCompare(getPoiDisplayName(right)); + } + + return getPoiDisplayName(left).localeCompare(getPoiDisplayName(right)); + }); + + return nextPois; + }, [pois, selectedPoiType, sortBy]); + + const selectedPoiTypeLabel = useMemo(() => { + if (selectedPoiType === ALL_POI_TYPES_VALUE) { + return t('poi.filter_all_types'); + } + + return poiTypes.find((poiType) => poiType.PoiTypeId.toString() === selectedPoiType)?.Name || t('poi.filter_all_types'); + }, [poiTypes, selectedPoiType, t]); + + const selectedSortLabel = useMemo(() => { + return sortBy === 'name' ? t('poi.sort_name') : t('poi.sort_type'); + }, [sortBy, t]); + + const renderPoiItem = useCallback>(({ item }) => , [onPoiPress, onViewOnMap]); + + const keyExtractor = useCallback((item: PoiResultData) => item.PoiId.toString(), []); + + if (isLoading && pois.length === 0) { + return ; + } + + if (error && pois.length === 0) { + return ; + } + + return ( + + + + {t('poi.list_description')} + + + + {t('poi.filter_label')} + + + + + {t('poi.sort_label')} + + + + + + + + {t('poi.results_count', { count: filteredPois.length })} + + + {t('poi.view_on_map_hint')} + + + + {filteredPois.length > 0 ? ( + + ) : ( + + )} + + ); +}; + +export default PoiListPanel; diff --git a/src/components/status/__tests__/personnel-status-bottom-sheet.test.tsx b/src/components/status/__tests__/personnel-status-bottom-sheet.test.tsx index 1233456..31c5665 100644 --- a/src/components/status/__tests__/personnel-status-bottom-sheet.test.tsx +++ b/src/components/status/__tests__/personnel-status-bottom-sheet.test.tsx @@ -212,6 +212,7 @@ describe('PersonnelStatusBottomSheet', () => { currentStep: 'select-responding-to' as const, selectedCall: null, selectedGroup: null, + selectedPoi: null, selectedStatus: null, responseType: 'none' as const, selectedTab: 'calls' as const, @@ -220,16 +221,20 @@ describe('PersonnelStatusBottomSheet', () => { isLoading: false, groups: [] as any[], isLoadingGroups: false, + pois: [] as any[], + isLoadingPois: false, setIsOpen: jest.fn(), setCurrentStep: jest.fn(), setSelectedCall: jest.fn(), setSelectedGroup: jest.fn(), + setSelectedPoi: jest.fn(), setResponseType: jest.fn(), setSelectedTab: jest.fn(), setNote: jest.fn(), setRespondingTo: jest.fn(), setIsLoading: jest.fn(), fetchGroups: jest.fn(), + fetchDestinationPois: jest.fn(), nextStep: jest.fn(), previousStep: jest.fn(), submitStatus: jest.fn(), @@ -238,6 +243,7 @@ describe('PersonnelStatusBottomSheet', () => { isDestinationRequired: jest.fn(() => true), areCallsAllowed: jest.fn(() => true), areStationsAllowed: jest.fn(() => true), + arePoisAllowed: jest.fn(() => false), getRequiredGpsAccuracy: jest.fn(() => false), goToNextStep: jest.fn(), }; @@ -280,6 +286,25 @@ describe('PersonnelStatusBottomSheet', () => { }, ]; + const mockPois = [ + { + PoiId: 101, + PoiTypeId: 1, + PoiTypeName: 'Hospital', + Name: 'Memorial Hospital', + Address: '300 Medical Plaza', + Note: 'ER Entrance', + }, + { + PoiId: 102, + PoiTypeId: 2, + PoiTypeName: 'Shelter', + Name: 'North Shelter', + Address: '400 Relief Ave', + Note: '', + }, + ]; + const mockStatus = { Id: 1, Text: 'Available', @@ -345,6 +370,15 @@ describe('PersonnelStatusBottomSheet', () => { }); it('should render "no destination" option', () => { + mockUsePersonnelStatusBottomSheetStore.mockReturnValue({ + ...mockStore, + isOpen: true, + selectedStatus: mockStatus, + currentStep: 'select-responding-to', + isDestinationRequired: jest.fn(() => false), + groups: mockGroups, + }); + render(); expect(screen.getByText('personnel.status.no_destination')).toBeTruthy(); @@ -358,6 +392,25 @@ describe('PersonnelStatusBottomSheet', () => { expect(screen.getByText('personnel.status.stations_tab')).toBeTruthy(); }); + it('should render the selected destination tab with readable light mode contrast', () => { + render(); + + expect(screen.getByTestId('status-destination-tab-calls').props.style).toMatchObject({ + backgroundColor: '#1d4ed8', + borderColor: '#1d4ed8', + }); + expect(screen.getByText('personnel.status.calls_tab').props.style).toMatchObject({ + color: '#ffffff', + }); + expect(screen.getByTestId('status-destination-tab-stations').props.style).toMatchObject({ + backgroundColor: '#f5f5f5', + borderColor: '#e5e5e5', + }); + expect(screen.getByText('personnel.status.stations_tab').props.style).toMatchObject({ + color: '#525252', + }); + }); + it('should render available calls in calls tab', () => { render(); @@ -404,6 +457,47 @@ describe('PersonnelStatusBottomSheet', () => { expect(mockSetSelectedTab).toHaveBeenCalledWith('stations'); }); + it('should render and handle POI tab selection when POIs are allowed', () => { + const mockSetSelectedTab = jest.fn(); + const mockSetSelectedPoi = jest.fn(); + + mockUsePersonnelStatusBottomSheetStore.mockReturnValue({ + ...mockStore, + isOpen: true, + selectedStatus: { ...mockStatus, Detail: 3 }, + currentStep: 'select-responding-to', + arePoisAllowed: jest.fn(() => true), + pois: mockPois, + setSelectedTab: mockSetSelectedTab, + setSelectedPoi: mockSetSelectedPoi, + groups: mockGroups, + }); + + render(); + + fireEvent.press(screen.getByText('personnel.status.pois_tab')); + expect(mockSetSelectedTab).toHaveBeenCalledWith('pois'); + }); + + it('should render available POIs when POI tab is selected', () => { + mockUsePersonnelStatusBottomSheetStore.mockReturnValue({ + ...mockStore, + isOpen: true, + selectedStatus: { ...mockStatus, Detail: 3 }, + currentStep: 'select-responding-to', + selectedTab: 'pois', + arePoisAllowed: jest.fn(() => true), + pois: mockPois, + groups: mockGroups, + }); + + render(); + + expect(screen.getByText('Hospital - Memorial Hospital - 300 Medical Plaza')).toBeTruthy(); + expect(screen.getByText('ER Entrance')).toBeTruthy(); + expect(screen.getByText('Shelter - North Shelter - 400 Relief Ave')).toBeTruthy(); + }); + it('should handle no destination selection', () => { const mockSetResponseType = jest.fn(); @@ -412,6 +506,7 @@ describe('PersonnelStatusBottomSheet', () => { isOpen: true, selectedStatus: mockStatus, currentStep: 'select-responding-to', + isDestinationRequired: jest.fn(() => false), setResponseType: mockSetResponseType, groups: mockGroups, }); @@ -649,8 +744,6 @@ describe('PersonnelStatusBottomSheet', () => { expect(screen.getByText('Available')).toBeTruthy(); expect(screen.getByText('personnel.status.responding_to:')).toBeTruthy(); expect(screen.getByText('CALL-001 - Test Call 1')).toBeTruthy(); - expect(screen.getByText('personnel.status.custom_responding_to:')).toBeTruthy(); - expect(screen.getByText('Test responding to')).toBeTruthy(); expect(screen.getByText('personnel.status.note:')).toBeTruthy(); expect(screen.getByText('Test note')).toBeTruthy(); }); @@ -962,25 +1055,31 @@ describe('PersonnelStatusBottomSheet', () => { render(); - expect(mockTrackEvent).toHaveBeenCalledWith('personnel_status_bottom_sheet_viewed', { - timestamp: expect.any(String), - currentStep: 'select-responding-to', - selectedStatusId: mockStatus.Id, - selectedStatusText: mockStatus.Text, - responseType: 'none', - selectedTab: 'calls', - hasSelectedCall: false, - selectedCallId: '', - hasSelectedGroup: false, - selectedGroupId: '', - hasNote: false, - noteLength: 0, - hasRespondingTo: false, - availableCallsCount: mockCalls.length, - availableGroupsCount: mockGroups.length, - hasActiveCall: false, - colorScheme: 'light', - }); + expect(mockTrackEvent).toHaveBeenCalledWith( + 'personnel_status_bottom_sheet_viewed', + expect.objectContaining({ + timestamp: expect.any(String), + currentStep: 'select-responding-to', + selectedStatusId: mockStatus.Id, + selectedStatusText: mockStatus.Text, + responseType: 'none', + selectedTab: 'calls', + hasSelectedCall: false, + selectedCallId: '', + hasSelectedGroup: false, + selectedGroupId: '', + hasSelectedPoi: false, + selectedPoiId: 0, + hasNote: false, + noteLength: 0, + hasRespondingTo: false, + availableCallsCount: mockCalls.length, + availableGroupsCount: mockGroups.length, + availablePoisCount: 0, + hasActiveCall: false, + colorScheme: 'light', + }) + ); }); it('should track analytics when step changes', () => { @@ -1000,25 +1099,31 @@ describe('PersonnelStatusBottomSheet', () => { rerender(); - expect(mockTrackEvent).toHaveBeenCalledWith('personnel_status_bottom_sheet_viewed', { - timestamp: expect.any(String), - currentStep: 'add-note', - selectedStatusId: mockStatus.Id, - selectedStatusText: mockStatus.Text, - responseType: 'none', - selectedTab: 'calls', - hasSelectedCall: false, - selectedCallId: '', - hasSelectedGroup: false, - selectedGroupId: '', - hasNote: false, - noteLength: 0, - hasRespondingTo: false, - availableCallsCount: mockCalls.length, - availableGroupsCount: mockGroups.length, - hasActiveCall: false, - colorScheme: 'light', - }); + expect(mockTrackEvent).toHaveBeenCalledWith( + 'personnel_status_bottom_sheet_viewed', + expect.objectContaining({ + timestamp: expect.any(String), + currentStep: 'add-note', + selectedStatusId: mockStatus.Id, + selectedStatusText: mockStatus.Text, + responseType: 'none', + selectedTab: 'calls', + hasSelectedCall: false, + selectedCallId: '', + hasSelectedGroup: false, + selectedGroupId: '', + hasSelectedPoi: false, + selectedPoiId: 0, + hasNote: false, + noteLength: 0, + hasRespondingTo: false, + availableCallsCount: mockCalls.length, + availableGroupsCount: mockGroups.length, + availablePoisCount: 0, + hasActiveCall: false, + colorScheme: 'light', + }) + ); }); it('should track analytics when call is selected', () => { @@ -1100,6 +1205,7 @@ describe('PersonnelStatusBottomSheet', () => { isOpen: true, selectedStatus: mockStatus, currentStep: 'select-responding-to', + isDestinationRequired: jest.fn(() => false), setResponseType: mockSetResponseType, groups: mockGroups, }); @@ -1168,14 +1274,18 @@ describe('PersonnelStatusBottomSheet', () => { fireEvent.press(screen.getByText('common.next')); - expect(mockTrackEvent).toHaveBeenCalledWith('personnel_status_step_next', { - timestamp: expect.any(String), - fromStep: 'select-responding-to', - selectedStatusId: mockStatus.Id, - responseType: 'call', - hasSelectedCall: true, - hasSelectedGroup: false, - }); + expect(mockTrackEvent).toHaveBeenCalledWith( + 'personnel_status_step_next', + expect.objectContaining({ + timestamp: expect.any(String), + fromStep: 'select-responding-to', + selectedStatusId: mockStatus.Id, + responseType: 'call', + hasSelectedCall: true, + hasSelectedGroup: false, + hasSelectedPoi: false, + }) + ); }); it('should track analytics when previous button is pressed', () => { @@ -1230,18 +1340,20 @@ describe('PersonnelStatusBottomSheet', () => { fireEvent.press(screen.getByText('common.submit')); - expect(mockTrackEvent).toHaveBeenCalledWith('personnel_status_submitted', { - timestamp: expect.any(String), - selectedStatusId: mockStatus.Id, - selectedStatusText: mockStatus.Text, - responseType: 'call', - selectedCallId: mockCalls[0]?.CallId, - selectedGroupId: '', - hasNote: true, - noteLength: 9, - hasRespondingTo: true, - respondingToLength: 18, - }); + expect(mockTrackEvent).toHaveBeenCalledWith( + 'personnel_status_submitted', + expect.objectContaining({ + timestamp: expect.any(String), + selectedStatusId: mockStatus.Id, + selectedStatusText: mockStatus.Text, + responseType: 'call', + selectedCallId: mockCalls[0]?.CallId, + selectedGroupId: '', + selectedPoiId: 0, + hasNote: true, + noteLength: 9, + }) + ); }); it('should track analytics when bottom sheet is closed', () => { @@ -1307,4 +1419,4 @@ describe('PersonnelStatusBottomSheet', () => { consoleSpy.mockRestore(); }); }); -}); \ No newline at end of file +}); diff --git a/src/components/status/personnel-status-bottom-sheet.tsx b/src/components/status/personnel-status-bottom-sheet.tsx index 2e04754..e51eeaf 100644 --- a/src/components/status/personnel-status-bottom-sheet.tsx +++ b/src/components/status/personnel-status-bottom-sheet.tsx @@ -1,11 +1,13 @@ import { ArrowLeft, ArrowRight, Check, X } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Platform, ScrollView, TouchableOpacity } from 'react-native'; import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; import { useAnalytics } from '@/hooks/use-analytics'; +import { arePoisAllowedForStatus, getCallDestinationDisplay, getPoiDestinationDisplay, getStationDestinationDisplay, type StatusDestinationTab } from '@/lib/status-destinations'; +import { invertColor } from '@/lib/utils'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; import { usePersonnelStatusBottomSheetStore } from '@/stores/status/personnel-status-store'; @@ -14,7 +16,6 @@ import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIn import { Button, ButtonText } from '../ui/button'; import { Heading } from '../ui/heading'; import { HStack } from '../ui/hstack'; -import { Input, InputField } from '../ui/input'; import { Spinner } from '../ui/spinner'; import { Text } from '../ui/text'; import { Textarea, TextareaInput } from '../ui/textarea'; @@ -25,9 +26,11 @@ export const PersonnelStatusBottomSheet = () => { const { trackEvent } = useAnalytics(); const { isOpen, + requiresStatusSelection = false, currentStep, selectedCall, selectedGroup, + selectedPoi = null, selectedStatus, responseType, selectedTab, @@ -36,34 +39,147 @@ export const PersonnelStatusBottomSheet = () => { isLoading, groups, isLoadingGroups, - setCurrentStep, + pois = [], + isLoadingPois = false, setSelectedCall, setSelectedGroup, + setSelectedPoi = () => undefined, + setSelectedStatus = () => undefined, setResponseType, setSelectedTab, setNote, - setRespondingTo, fetchGroups, + fetchDestinationPois = async () => undefined, nextStep, previousStep, submitStatus, reset, + isDestinationRequired = () => false, + areCallsAllowed = () => true, + areStationsAllowed = () => true, + arePoisAllowed = () => false, } = usePersonnelStatusBottomSheetStore(); - const { activeCall } = useCoreStore(); + const { activeCall, activeStatuses } = useCoreStore(); const { calls, isLoading: isLoadingCalls, fetchCalls } = useCallsStore(); const { colorScheme } = useColorScheme(); - // Fetch calls and groups when bottom sheet opens - React.useEffect(() => { + // Refs for volatile values read inside trackViewAnalytics so the callback + // stays stable and does not fire on every unrelated state change. + const requiresStatusSelectionRef = useRef(requiresStatusSelection); + const selectedStatusRef = useRef(selectedStatus); + const responseTypeRef = useRef(responseType); + const selectedTabRef = useRef(selectedTab); + const selectedCallRef = useRef(selectedCall); + const selectedGroupRef = useRef(selectedGroup); + const selectedPoiRef = useRef(selectedPoi); + const noteRef = useRef(note); + const respondingToRef = useRef(respondingTo); + const callsRef = useRef(calls); + const groupsRef = useRef(groups); + const poisRef = useRef(pois); + const activeCallRef = useRef(activeCall); + const colorSchemeRef = useRef(colorScheme); + + // Keep refs in sync with the latest values on every render. + requiresStatusSelectionRef.current = requiresStatusSelection; + selectedStatusRef.current = selectedStatus; + responseTypeRef.current = responseType; + selectedTabRef.current = selectedTab; + selectedCallRef.current = selectedCall; + selectedGroupRef.current = selectedGroup; + selectedPoiRef.current = selectedPoi; + noteRef.current = note; + respondingToRef.current = respondingTo; + callsRef.current = calls; + groupsRef.current = groups; + poisRef.current = pois; + activeCallRef.current = activeCall; + colorSchemeRef.current = colorScheme; + + const callsAllowed = areCallsAllowed(); + const stationsAllowed = areStationsAllowed(); + const poisAllowed = arePoisAllowed(); + const destinationRequired = isDestinationRequired(); + const totalSteps = requiresStatusSelection ? 4 : 3; + + const allowedTabs = useMemo(() => { + const tabs: StatusDestinationTab[] = []; + + if (callsAllowed) { + tabs.push('calls'); + } + + if (stationsAllowed) { + tabs.push('stations'); + } + + if (poisAllowed) { + tabs.push('pois'); + } + + return tabs; + }, [callsAllowed, poisAllowed, stationsAllowed]); + + const visibleStatuses = useMemo(() => { + const nextStatuses = activeStatuses || []; + return selectedPoi ? nextStatuses.filter((status) => arePoisAllowedForStatus(status.Detail)) : nextStatuses; + }, [activeStatuses, selectedPoi]); + + useEffect(() => { if (isOpen) { fetchCalls(); - fetchGroups(); + void fetchGroups(); + void fetchDestinationPois(); + } + }, [fetchCalls, fetchDestinationPois, fetchGroups, isOpen]); + + useEffect(() => { + if (activeCall && currentStep === 'select-responding-to' && responseType === 'none' && callsAllowed && !selectedGroup && !selectedPoi) { + setSelectedCall(activeCall); + } + }, [activeCall, callsAllowed, currentStep, responseType, selectedGroup, selectedPoi, setSelectedCall]); + + const trackViewAnalytics = useCallback(() => { + try { + trackEvent('personnel_status_bottom_sheet_viewed', { + timestamp: new Date().toISOString(), + currentStep, + requiresStatusSelection: requiresStatusSelectionRef.current, + selectedStatusId: selectedStatusRef.current?.Id ?? 0, + selectedStatusText: selectedStatusRef.current?.Text ?? '', + responseType: responseTypeRef.current, + selectedTab: selectedTabRef.current, + hasSelectedCall: !!selectedCallRef.current, + selectedCallId: selectedCallRef.current?.CallId ?? '', + hasSelectedGroup: !!selectedGroupRef.current, + selectedGroupId: selectedGroupRef.current?.GroupId ?? '', + hasSelectedPoi: !!selectedPoiRef.current, + selectedPoiId: selectedPoiRef.current?.PoiId ?? 0, + hasNote: noteRef.current.length > 0, + noteLength: noteRef.current.length, + hasRespondingTo: respondingToRef.current.length > 0, + availableCallsCount: callsRef.current?.length || 0, + availableGroupsCount: groupsRef.current?.length || 0, + availablePoisCount: poisRef.current?.length || 0, + hasActiveCall: !!activeCallRef.current, + colorScheme: colorSchemeRef.current || 'light', + }); + } catch (error) { + console.warn('Failed to track personnel status bottom sheet view analytics:', error); + } + }, [trackEvent, currentStep]); + + const trackViewAnalyticsRef = useRef(trackViewAnalytics); + trackViewAnalyticsRef.current = trackViewAnalytics; + + useEffect(() => { + if (isOpen) { + trackViewAnalyticsRef.current(); } - }, [isOpen, fetchCalls, fetchGroups]); + }, [isOpen, currentStep]); const handleClose = () => { - // Track close analytics try { trackEvent('personnel_status_bottom_sheet_closed', { timestamp: new Date().toISOString(), @@ -72,8 +188,9 @@ export const PersonnelStatusBottomSheet = () => { responseType, hasSelectedCall: !!selectedCall, hasSelectedGroup: !!selectedGroup, + hasSelectedPoi: !!selectedPoi, hasNote: note.length > 0, - completed: false, // User closed without completing + completed: false, }); } catch (error) { console.warn('Failed to track personnel status bottom sheet close analytics:', error); @@ -82,50 +199,96 @@ export const PersonnelStatusBottomSheet = () => { reset(); }; + const handleStatusSelect = (statusId: number) => { + const status = visibleStatuses.find((currentStatus) => currentStatus.Id === statusId); + + if (!status) { + return; + } + + setSelectedStatus(status); + + try { + trackEvent('personnel_status_option_selected', { + timestamp: new Date().toISOString(), + statusId: status.Id, + statusText: status.Text, + statusDetail: status.Detail, + }); + } catch (error) { + console.warn('Failed to track status option analytics:', error); + } + }; + const handleCallSelect = (callId: string) => { - const call = calls.find((c) => c.CallId === callId); - if (call) { - setSelectedCall(call); - - // Track call selection analytics - try { - trackEvent('personnel_status_call_selected', { - timestamp: new Date().toISOString(), - callId: call.CallId, - callNumber: call.Number || '', - callName: call.Name || '', - currentStep, - }); - } catch (error) { - console.warn('Failed to track call selection analytics:', error); - } + const call = calls.find((currentCall) => currentCall.CallId === callId); + + if (!call) { + return; + } + + setSelectedCall(call); + + try { + trackEvent('personnel_status_call_selected', { + timestamp: new Date().toISOString(), + callId: call.CallId, + callNumber: call.Number || '', + callName: call.Name || '', + currentStep, + }); + } catch (error) { + console.warn('Failed to track call selection analytics:', error); } }; const handleGroupSelect = (groupId: string) => { - const group = groups.find((g) => g.GroupId === groupId); - if (group) { - setSelectedGroup(group); - - // Track group selection analytics - try { - trackEvent('personnel_status_group_selected', { - timestamp: new Date().toISOString(), - groupId: group.GroupId, - groupName: group.Name || '', - groupType: group.GroupType || '', - currentStep, - }); - } catch (error) { - console.warn('Failed to track group selection analytics:', error); - } + const group = groups.find((currentGroup) => currentGroup.GroupId === groupId); + + if (!group) { + return; + } + + setSelectedGroup(group); + + try { + trackEvent('personnel_status_group_selected', { + timestamp: new Date().toISOString(), + groupId: group.GroupId, + groupName: group.Name || '', + groupType: group.GroupType || '', + currentStep, + }); + } catch (error) { + console.warn('Failed to track group selection analytics:', error); + } + }; + + const handlePoiSelect = (poiId: number) => { + const poi = pois.find((currentPoi) => currentPoi.PoiId === poiId); + + if (!poi) { + return; + } + + setSelectedPoi(poi); + + try { + trackEvent('personnel_status_poi_selected', { + timestamp: new Date().toISOString(), + poiId: poi.PoiId, + poiTypeId: poi.PoiTypeId, + poiTypeName: poi.PoiTypeName || '', + currentStep, + }); + } catch (error) { + console.warn('Failed to track POI selection analytics:', error); } }; const handleNoDestinationSelect = () => { setResponseType('none'); - // Track no destination selection analytics try { trackEvent('personnel_status_no_destination_selected', { timestamp: new Date().toISOString(), @@ -138,7 +301,6 @@ export const PersonnelStatusBottomSheet = () => { }; const handleNext = () => { - // Track step progression analytics try { trackEvent('personnel_status_step_next', { timestamp: new Date().toISOString(), @@ -147,6 +309,7 @@ export const PersonnelStatusBottomSheet = () => { responseType, hasSelectedCall: !!selectedCall, hasSelectedGroup: !!selectedGroup, + hasSelectedPoi: !!selectedPoi, }); } catch (error) { console.warn('Failed to track step next analytics:', error); @@ -156,7 +319,6 @@ export const PersonnelStatusBottomSheet = () => { }; const handlePrevious = () => { - // Track step backward analytics try { trackEvent('personnel_status_step_previous', { timestamp: new Date().toISOString(), @@ -172,7 +334,6 @@ export const PersonnelStatusBottomSheet = () => { }; const handleSubmit = async () => { - // Track submission analytics try { trackEvent('personnel_status_submitted', { timestamp: new Date().toISOString(), @@ -181,10 +342,9 @@ export const PersonnelStatusBottomSheet = () => { responseType, selectedCallId: selectedCall?.CallId ?? '', selectedGroupId: selectedGroup?.GroupId ?? '', + selectedPoiId: selectedPoi?.PoiId ?? 0, hasNote: note.length > 0, noteLength: note.length, - hasRespondingTo: respondingTo.length > 0, - respondingToLength: respondingTo.length, }); } catch (error) { console.warn('Failed to track submission analytics:', error); @@ -193,11 +353,10 @@ export const PersonnelStatusBottomSheet = () => { await submitStatus(); }; - const handleTabSelect = (tab: 'calls' | 'stations') => { + const handleTabSelect = (tab: StatusDestinationTab) => { const fromTab = selectedTab; setSelectedTab(tab); - // Track tab selection analytics try { trackEvent('personnel_status_tab_changed', { timestamp: new Date().toISOString(), @@ -211,50 +370,10 @@ export const PersonnelStatusBottomSheet = () => { } }; - // Auto-select active call if available and no destination is selected - React.useEffect(() => { - if (activeCall && currentStep === 'select-responding-to' && responseType === 'none') { - setSelectedCall(activeCall); - } - }, [activeCall, currentStep, responseType, setSelectedCall]); - - // Analytics tracking function - const trackViewAnalytics = useCallback(() => { - try { - trackEvent('personnel_status_bottom_sheet_viewed', { - timestamp: new Date().toISOString(), - currentStep, - selectedStatusId: selectedStatus?.Id ?? 0, - selectedStatusText: selectedStatus?.Text ?? '', - responseType, - selectedTab, - hasSelectedCall: !!selectedCall, - selectedCallId: selectedCall?.CallId ?? '', - hasSelectedGroup: !!selectedGroup, - selectedGroupId: selectedGroup?.GroupId ?? '', - hasNote: note.length > 0, - noteLength: note.length, - hasRespondingTo: respondingTo.length > 0, - availableCallsCount: calls?.length || 0, - availableGroupsCount: groups?.length || 0, - hasActiveCall: !!activeCall, - colorScheme: colorScheme || 'light', - }); - } catch (error) { - // Analytics errors should not break the component - console.warn('Failed to track personnel status bottom sheet view analytics:', error); - } - }, [trackEvent, currentStep, selectedStatus, responseType, selectedTab, selectedCall, selectedGroup, note, respondingTo, calls, groups, activeCall, colorScheme]); - - // Track analytics when sheet becomes visible or step changes - useEffect(() => { - if (isOpen) { - trackViewAnalytics(); - } - }, [isOpen, currentStep, trackViewAnalytics]); - const getStepTitle = () => { switch (currentStep) { + case 'select-status': + return t('personnel.status.set_status'); case 'select-responding-to': return t('personnel.status.select_responding_to', { status: selectedStatus?.Text }); case 'add-note': @@ -267,6 +386,21 @@ export const PersonnelStatusBottomSheet = () => { }; const getStepNumber = () => { + if (requiresStatusSelection) { + switch (currentStep) { + case 'select-status': + return 1; + case 'select-responding-to': + return 2; + case 'add-note': + return 3; + case 'confirm': + return 4; + default: + return 1; + } + } + switch (currentStep) { case 'select-responding-to': return 1; @@ -281,12 +415,32 @@ export const PersonnelStatusBottomSheet = () => { const canProceedFromCurrentStep = () => { switch (currentStep) { + case 'select-status': + return selectedStatus !== null; case 'select-responding-to': - // "No Destination" is always valid regardless of status Detail value - // User can always proceed with any selection (call, station, or none) - return true; + if (!selectedStatus) { + return false; + } + + if (!destinationRequired) { + return true; + } + + if (responseType === 'call') { + return selectedCall !== null; + } + + if (responseType === 'station') { + return selectedGroup !== null; + } + + if (responseType === 'poi') { + return selectedPoi !== null; + } + + return false; case 'add-note': - return true; // Note is optional + return true; case 'confirm': return true; default: @@ -296,14 +450,26 @@ export const PersonnelStatusBottomSheet = () => { const getSelectedDestinationDisplay = () => { if (responseType === 'call' && selectedCall) { - return `${selectedCall.Number} - ${selectedCall.Name}`; - } else if (responseType === 'station' && selectedGroup) { - return selectedGroup.Name; - } else { - return t('personnel.status.no_destination'); + return getCallDestinationDisplay(selectedCall); } + + if (responseType === 'station' && selectedGroup) { + return getStationDestinationDisplay(selectedGroup); + } + + if (responseType === 'poi' && selectedPoi) { + return getPoiDestinationDisplay(selectedPoi); + } + + return t('personnel.status.no_destination'); }; + const selectedDestinationTabBackgroundColor = colorScheme === 'dark' ? '#2563eb' : '#1d4ed8'; + const selectedDestinationTabBorderColor = colorScheme === 'dark' ? '#60a5fa' : '#1d4ed8'; + const unselectedDestinationTabBackgroundColor = colorScheme === 'dark' ? '#171717' : '#f5f5f5'; + const unselectedDestinationTabBorderColor = colorScheme === 'dark' ? '#262626' : '#e5e5e5'; + const unselectedDestinationTabTextColor = colorScheme === 'dark' ? '#d4d4d8' : '#525252'; + return ( @@ -313,11 +479,10 @@ export const PersonnelStatusBottomSheet = () => { - {/* Step indicator with close button */} - {t('common.step')} {getStepNumber()} {t('common.of')} 3 + {t('common.step')} {getStepNumber()} {t('common.of')} {totalSteps} @@ -330,135 +495,263 @@ export const PersonnelStatusBottomSheet = () => { {getStepTitle()} - {currentStep === 'select-responding-to' && ( + {currentStep === 'select-status' ? ( - {t('personnel.status.select_destination')} + {t('personnel.status.status')} - {/* No Destination Option */} - - - - {responseType === 'none' && } + + {activeStatuses === null ? ( + + + {t('common.loading')} - - {t('personnel.status.no_destination')} - {t('personnel.status.general_status')} - - - + ) : visibleStatuses.length > 0 ? ( + visibleStatuses.map((status) => { + const isSelected = selectedStatus?.Id === status.Id; + const textColor = invertColor(status.BColor, true); + + return ( + handleStatusSelect(status.Id)} + className={`mb-3 rounded-lg border-2 p-3 ${isSelected ? 'border-primary-500 dark:border-primary-400' : 'border-transparent'}`} + style={{ backgroundColor: status.BColor }} + > + + + {isSelected ? : null} + + + + {status.Text} + + + + + ); + }) + ) : ( + {t('home.status.no_options_available')} + )} + - {/* Tab Headers */} - - handleTabSelect('calls')} className={`flex-1 rounded-lg py-3 ${selectedTab === 'calls' ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`}> - {t('personnel.status.calls_tab')} - - handleTabSelect('stations')} className={`flex-1 rounded-lg py-3 ${selectedTab === 'stations' ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`}> - {t('personnel.status.stations_tab')} - + + + + + ) : null} + + {currentStep === 'select-responding-to' ? ( + + {t('personnel.status.select_destination')} + + {!destinationRequired ? ( + + + + {responseType === 'none' ? : null} + + + {t('personnel.status.no_destination')} + {t('personnel.status.general_status')} + + + + ) : null} + + {allowedTabs.length > 1 ? ( + + {allowedTabs.map((tab) => { + const isSelected = selectedTab === tab; + + return ( + handleTabSelect(tab)} + className="flex-1 rounded-xl border p-3" + style={{ + backgroundColor: isSelected ? selectedDestinationTabBackgroundColor : unselectedDestinationTabBackgroundColor, + borderColor: isSelected ? selectedDestinationTabBorderColor : unselectedDestinationTabBorderColor, + }} + > + + {tab === 'calls' ? t('personnel.status.calls_tab') : tab === 'stations' ? t('personnel.status.stations_tab') : t('personnel.status.pois_tab')} + + + ); + })} + + ) : null} - {/* Tab Content */} - {selectedTab === 'calls' && ( + {selectedTab === 'calls' && callsAllowed ? ( {isLoadingCalls ? ( - + {t('calls.loading_calls')} - ) : calls && calls.length > 0 ? ( - calls.map((call) => ( - handleCallSelect(call.CallId)} - className={`mb-3 rounded-lg border-2 p-3 ${selectedCall?.CallId === call.CallId ? 'border-primary-500 bg-primary-50 dark:border-primary-400 dark:bg-primary-900/20' : 'border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800'}`} - > - - - {selectedCall?.CallId === call.CallId && } - - - - {call.Number} - {call.Name} - - {call.Address} - - - - )) + ) : calls.length > 0 ? ( + calls.map((call) => { + const isSelected = selectedCall?.CallId === call.CallId; + + return ( + handleCallSelect(call.CallId)} + className={`mb-3 rounded-lg border-2 p-3 ${isSelected ? 'border-primary-500 bg-primary-50 dark:border-primary-400 dark:bg-primary-900/20' : 'border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800'}`} + > + + + {isSelected ? : null} + + + {getCallDestinationDisplay(call)} + {call.Address ? {call.Address} : null} + + + + ); + }) ) : ( {t('calls.no_calls_available')} )} - )} + ) : null} - {selectedTab === 'stations' && ( + {selectedTab === 'stations' && stationsAllowed ? ( {isLoadingGroups ? ( - + {t('personnel.status.loading_stations')} - ) : groups && groups.length > 0 ? ( - groups.map((group) => ( - handleGroupSelect(group.GroupId)} - className={`mb-3 rounded-lg border-2 p-3 ${selectedGroup?.GroupId === group.GroupId ? 'border-primary-500 bg-primary-50 dark:border-primary-400 dark:bg-primary-900/20' : 'border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800'}`} - > - - - {selectedGroup?.GroupId === group.GroupId && } - - - {group.Name} - {group.Address && {group.Address}} - {group.GroupType && {group.GroupType}} - - - - )) + ) : groups.length > 0 ? ( + groups.map((group) => { + const isSelected = selectedGroup?.GroupId === group.GroupId; + + return ( + handleGroupSelect(group.GroupId)} + className={`mb-3 rounded-lg border-2 p-3 ${isSelected ? 'border-primary-500 bg-primary-50 dark:border-primary-400 dark:bg-primary-900/20' : 'border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800'}`} + > + + + {isSelected ? : null} + + + {getStationDestinationDisplay(group)} + {group.Address ? {group.Address} : null} + {group.GroupType ? {group.GroupType} : null} + + + + ); + }) ) : ( {t('personnel.status.no_stations_available')} )} - )} + ) : null} + + {selectedTab === 'pois' && poisAllowed ? ( + + {isLoadingPois ? ( + + + {t('personnel.status.loading_pois')} + + ) : pois.length > 0 ? ( + pois.map((poi) => { + const isSelected = selectedPoi?.PoiId === poi.PoiId; + + return ( + handlePoiSelect(poi.PoiId)} + className={`mb-3 rounded-lg border-2 p-3 ${isSelected ? 'border-primary-500 bg-primary-50 dark:border-primary-400 dark:bg-primary-900/20' : 'border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800'}`} + > + + + {isSelected ? : null} + + + {getPoiDestinationDisplay(poi)} + {poi.Address ? {poi.Address} : null} + {poi.Note ? {poi.Note} : null} + + + + ); + }) + ) : ( + {t('poi.empty_title')} + )} + + ) : null} - + {requiresStatusSelection ? ( + + ) : ( + + )} - )} + ) : null} - {currentStep === 'add-note' && ( + {currentStep === 'add-note' ? ( @@ -482,14 +775,14 @@ export const PersonnelStatusBottomSheet = () => { - )} + ) : null} - {currentStep === 'confirm' && ( + {currentStep === 'confirm' ? ( {t('personnel.status.review_and_confirm')} @@ -504,19 +797,19 @@ export const PersonnelStatusBottomSheet = () => { {getSelectedDestinationDisplay()} - {respondingTo && ( + {responseType === 'none' && respondingTo ? ( {t('personnel.status.custom_responding_to')}: {respondingTo} - )} + ) : null} - {note && ( + {note ? ( {t('personnel.status.note')}: {note} - )} + ) : null} @@ -529,7 +822,7 @@ export const PersonnelStatusBottomSheet = () => { - )} + ) : null} diff --git a/src/components/status/store.ts b/src/components/status/store.ts index bfa7712..e222f59 100644 --- a/src/components/status/store.ts +++ b/src/components/status/store.ts @@ -1,12 +1,15 @@ import { create } from 'zustand'; import { getCalls } from '@/api/calls/calls'; +import { getSetUnitStatusData } from '@/api/dispatch'; import { getAllGroups } from '@/api/groups/groups'; import { saveUnitStatus } from '@/api/units/unitStatuses'; import { logger } from '@/lib/logging'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { type CustomStatusResultData } from '@/models/v4/customStatuses/customStatusResultData'; import { type GroupResultData } from '@/models/v4/groups/groupsResultData'; +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; +import { type PoiTypeResultData } from '@/models/v4/mapping/poiTypeResultData'; import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; import { type SaveUnitStatusInput, type SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; import { offlineEventManager } from '@/services/offline-event-manager.service'; @@ -14,7 +17,7 @@ import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; type StatusStep = 'select-status' | 'select-destination' | 'add-note'; -type DestinationType = 'none' | 'call' | 'station'; +type DestinationType = 'none' | 'call' | 'station' | 'poi'; // Status type that can accept both custom statuses and regular statuses type StatusType = CustomStatusResultData | StatusesResultData; @@ -24,18 +27,22 @@ interface StatusBottomSheetStore { currentStep: StatusStep; selectedCall: CallResultData | null; selectedStation: GroupResultData | null; + selectedPoi: PoiResultData | null; selectedDestinationType: DestinationType; selectedStatus: StatusType | null; cameFromStatusSelection: boolean; // Track whether we came from status selection flow note: string; availableCalls: CallResultData[]; availableStations: GroupResultData[]; + availablePois: PoiResultData[]; + poiTypes: PoiTypeResultData[]; isLoading: boolean; error: string | null; setIsOpen: (isOpen: boolean, status?: StatusType) => void; setCurrentStep: (step: StatusStep) => void; setSelectedCall: (call: CallResultData | null) => void; setSelectedStation: (station: GroupResultData | null) => void; + setSelectedPoi: (poi: PoiResultData | null) => void; setSelectedDestinationType: (type: DestinationType) => void; setSelectedStatus: (status: StatusType | null) => void; setNote: (note: string) => void; @@ -48,12 +55,15 @@ export const useStatusBottomSheetStore = create((set, ge currentStep: 'select-destination', selectedCall: null, selectedStation: null, + selectedPoi: null, selectedDestinationType: 'none', selectedStatus: null, cameFromStatusSelection: false, note: '', availableCalls: [], availableStations: [], + availablePois: [], + poiTypes: [], isLoading: false, error: null, setIsOpen: (isOpen, status) => { @@ -66,20 +76,68 @@ export const useStatusBottomSheetStore = create((set, ge } }, setCurrentStep: (step) => set({ currentStep: step }), - setSelectedCall: (call) => set({ selectedCall: call }), - setSelectedStation: (station) => set({ selectedStation: station }), - setSelectedDestinationType: (type) => set({ selectedDestinationType: type }), + setSelectedCall: (call) => + set({ + selectedCall: call, + selectedStation: null, + selectedPoi: null, + selectedDestinationType: call ? 'call' : 'none', + }), + setSelectedStation: (station) => + set({ + selectedCall: null, + selectedStation: station, + selectedPoi: null, + selectedDestinationType: station ? 'station' : 'none', + }), + setSelectedPoi: (poi) => + set({ + selectedCall: null, + selectedStation: null, + selectedPoi: poi, + selectedDestinationType: poi ? 'poi' : 'none', + }), + setSelectedDestinationType: (type) => + set((state) => + type === 'none' + ? { + ...state, + selectedCall: null, + selectedStation: null, + selectedPoi: null, + selectedDestinationType: 'none', + } + : { + ...state, + selectedDestinationType: type, + } + ), setSelectedStatus: (status) => set({ selectedStatus: status }), setNote: (note) => set({ note }), fetchDestinationData: async (unitId: string) => { set({ isLoading: true, error: null }); try { - // Fetch calls and groups (stations) in parallel + const bootstrapResponse = await getSetUnitStatusData(unitId); + const unitStatusData = bootstrapResponse.Data; + + if (unitStatusData) { + set({ + availableCalls: unitStatusData.Calls || [], + availableStations: unitStatusData.Stations || [], + availablePois: unitStatusData.DestinationPois || [], + poiTypes: unitStatusData.PoiTypes || [], + isLoading: false, + }); + return; + } + const [callsResponse, groupsResponse] = await Promise.all([getCalls(), getAllGroups()]); set({ availableCalls: callsResponse.Data || [], availableStations: groupsResponse.Data || [], + availablePois: [], + poiTypes: [], isLoading: false, }); } catch (error) { @@ -95,12 +153,15 @@ export const useStatusBottomSheetStore = create((set, ge currentStep: 'select-destination', selectedCall: null, selectedStation: null, + selectedPoi: null, selectedDestinationType: 'none', selectedStatus: null, cameFromStatusSelection: false, note: '', availableCalls: [], availableStations: [], + availablePois: [], + poiTypes: [], isLoading: false, error: null, }), @@ -214,7 +275,7 @@ export const useStatusesStore = create((set) => ({ } // Queue the event - const eventId = offlineEventManager.queueUnitStatusEvent(payload.Id, payload.Type, payload.Note, payload.RespondingTo, roles, gpsData); + const eventId = offlineEventManager.queueUnitStatusEvent(payload.Id, payload.Type, payload.Note, payload.RespondingTo, roles, gpsData, payload.RespondingToType, payload.EventId); logger.info({ message: 'Unit status queued for offline processing', diff --git a/src/components/ui/shared-tabs.tsx b/src/components/ui/shared-tabs.tsx index 7a54171..e303760 100644 --- a/src/components/ui/shared-tabs.tsx +++ b/src/components/ui/shared-tabs.tsx @@ -87,11 +87,6 @@ export const SharedTabs: React.FC = ({ [onChange, setActiveIndex] ); - // Get appropriate text color based on theme - const getTextColor = () => { - return colorScheme === 'dark' ? 'text-gray-200' : 'text-gray-800'; - }; - // Get font size for title text const getTitleFontSize = () => { if (titleFontSize) { @@ -114,21 +109,32 @@ export const SharedTabs: React.FC = ({ const baseStyles = 'flex items-center justify-center'; const sizeStyles = { - sm: variant === 'segmented' ? (isLandscape ? 'px-3 py-1' : 'px-2 py-0.5') : isLandscape ? 'px-3 py-1.5' : 'px-2 py-1', - md: isLandscape ? 'px-4 py-2' : 'px-3 py-1.5', - lg: isLandscape ? 'px-5 py-2.5' : 'px-4 py-2', + sm: variant === 'segmented' ? (isLandscape ? 'px-3 py-1.5' : 'px-3 py-1') : isLandscape ? 'px-3 py-1.5' : 'px-2 py-1', + md: variant === 'segmented' ? (isLandscape ? 'px-4 py-2.5' : 'px-4 py-2') : isLandscape ? 'px-4 py-2' : 'px-3 py-1.5', + lg: variant === 'segmented' ? (isLandscape ? 'px-5 py-3' : 'px-4 py-2.5') : isLandscape ? 'px-5 py-2.5' : 'px-4 py-2', }[size]; const variantStyles = { default: isActive ? 'border-b-2 border-primary-500 text-primary-500' : `border-b-2 border-transparent ${colorScheme === 'dark' ? 'text-gray-400' : 'text-gray-500'}`, pills: isActive ? 'bg-primary-500 text-white rounded-full' : `bg-transparent ${colorScheme === 'dark' ? 'text-gray-400' : 'text-gray-500'}`, underlined: isActive ? 'border-b-2 border-primary-500 text-primary-500' : `border-b-2 border-transparent ${colorScheme === 'dark' ? 'text-gray-400' : 'text-gray-500'}`, - segmented: isActive ? 'bg-primary-500 text-white' : `${colorScheme === 'dark' ? 'bg-gray-800 text-gray-400' : 'bg-gray-100 text-gray-500'}`, + segmented: isActive ? 'bg-primary-600 shadow-sm dark:bg-primary-500' : 'bg-transparent', }[variant]; return `${baseStyles} ${sizeStyles} ${variantStyles} ${tabClassName}`; }; + const getTitleClassName = (index: number) => { + const isActive = index === currentIndex; + const baseStyles = isActive ? 'font-semibold' : 'font-medium'; + + if (variant === 'segmented' || variant === 'pills') { + return `${baseStyles} ${isActive ? 'text-white' : colorScheme === 'dark' ? 'text-neutral-300' : 'text-neutral-600'}`; + } + + return `${baseStyles} ${isActive ? (colorScheme === 'dark' ? 'text-primary-400' : 'text-primary-600') : colorScheme === 'dark' ? 'text-neutral-400' : 'text-neutral-500'}`; + }; + // Container styles based on variant const getContainerStyles = () => { const baseStyles = 'flex flex-row'; @@ -137,7 +143,7 @@ export const SharedTabs: React.FC = ({ default: colorScheme === 'dark' ? 'border-b border-gray-700' : 'border-b border-gray-200', pills: 'space-x-2 p-1', underlined: colorScheme === 'dark' ? 'border-b border-gray-700' : 'border-b border-gray-200', - segmented: colorScheme === 'dark' ? 'bg-gray-800 p-1 rounded-lg' : 'bg-gray-100 p-1 rounded-lg', + segmented: colorScheme === 'dark' ? 'rounded-2xl border border-neutral-800 bg-neutral-900 p-1.5' : 'rounded-2xl border border-neutral-200 bg-neutral-100 p-1.5', }[variant]; return `${baseStyles} ${variantStyles} ${tabsContainerClassName}`; @@ -146,7 +152,7 @@ export const SharedTabs: React.FC = ({ // Convert Tailwind classes to style object const getContainerStyle = () => { const borderColor = colorScheme === 'dark' ? '#374151' : '#e5e7eb'; - const backgroundColor = colorScheme === 'dark' ? '#1f2937' : '#f3f4f6'; + const backgroundColor = colorScheme === 'dark' ? '#171717' : '#f5f5f5'; const styles = StyleSheet.create({ container: { @@ -154,7 +160,7 @@ export const SharedTabs: React.FC = ({ ...(variant === 'default' && { borderBottomWidth: 1, borderBottomColor: borderColor }), ...(variant === 'pills' && { gap: 8, padding: 4 }), ...(variant === 'underlined' && { borderBottomWidth: 1, borderBottomColor: borderColor }), - ...(variant === 'segmented' && { backgroundColor, padding: 4, borderRadius: 8 }), + ...(variant === 'segmented' && { backgroundColor, padding: 6, borderRadius: 16, borderWidth: 1, borderColor }), }, }); return styles.container; @@ -168,7 +174,11 @@ export const SharedTabs: React.FC = ({ {tabs.map((tab, index) => ( handleTabPress(index)}> {tab.icon && {tab.icon}} - {typeof tab.title === 'string' ? {t(tab.title)} : {tab.title}} + {typeof tab.title === 'string' ? ( + {t(tab.title)} + ) : ( + {tab.title} + )} {tab.badge !== undefined && tab.badge > 0 && ( {tab.badge} @@ -182,7 +192,11 @@ export const SharedTabs: React.FC = ({ {tabs.map((tab, index) => ( handleTabPress(index)}> {tab.icon && {tab.icon}} - {typeof tab.title === 'string' ? {t(tab.title)} : {tab.title}} + {typeof tab.title === 'string' ? ( + {t(tab.title)} + ) : ( + {tab.title} + )} {tab.badge !== undefined && tab.badge > 0 && ( {tab.badge} diff --git a/src/components/units/unit-card.tsx b/src/components/units/unit-card.tsx index 312d668..a6203fe 100644 --- a/src/components/units/unit-card.tsx +++ b/src/components/units/unit-card.tsx @@ -88,6 +88,7 @@ export const UnitCard: React.FC = ({ unit, unitTypeStatuses, onPr // Find the status data from unit type statuses (custom statuses) const customStatusData = statusId ? findUnitStatus(unitType, statusId, unitTypeStatuses) : null; + const destinationText = 'CurrentDestinationName' in unit ? unit.CurrentDestinationName : ''; // Fall back to default status if custom status not found (used for colors only) const defaultStatus = statusId && !customStatusData ? getDefaultStatus(statusId) : null; @@ -121,6 +122,11 @@ export const UnitCard: React.FC = ({ unit, unitTypeStatuses, onPr {unit.Type && {unit.Type}} + {destinationText ? ( + + {t('call_detail.destination')}: {destinationText} + + ) : null} {unit.GroupName ? ( diff --git a/src/components/units/unit-details-sheet.tsx b/src/components/units/unit-details-sheet.tsx index 685b3b9..4cb7c3f 100644 --- a/src/components/units/unit-details-sheet.tsx +++ b/src/components/units/unit-details-sheet.tsx @@ -250,6 +250,14 @@ export const UnitDetailsSheet: React.FC = React.memo(() => { {/* Status Information */} + {'CurrentDestinationName' in selectedUnit && selectedUnit.CurrentDestinationName ? ( + + + + {t('call_detail.destination')}: {selectedUnit.CurrentDestinationName} + + + ) : null} {statusTimestamp && ( diff --git a/src/constants/map-icons.ts b/src/constants/map-icons.ts index 54f778c..91651d4 100644 --- a/src/constants/map-icons.ts +++ b/src/constants/map-icons.ts @@ -220,3 +220,27 @@ export const MAP_ICONS = { uri: require('../../assets/mapping/worksite.png'), }, }; + +export type MapIconKey = keyof typeof MAP_ICONS; + +const MAP_ICON_KEYS = new Set(Object.keys(MAP_ICONS)); + +const normalizeMapIconToken = (value: string) => { + return value + .trim() + .toLowerCase() + .replace(/\.[a-z0-9]+$/i, '') + .replace(/[^a-z0-9]+/g, ''); +}; + +export const resolveMapIconKey = ({ imagePath, marker, fallback = 'call' }: { imagePath?: string | null; marker?: string | null; fallback?: MapIconKey }): MapIconKey => { + const tokens = [imagePath, marker].filter((value): value is string => typeof value === 'string' && value.trim().length > 0).map(normalizeMapIconToken); + + for (const token of tokens) { + if (MAP_ICON_KEYS.has(token)) { + return token as MapIconKey; + } + } + + return fallback; +}; diff --git a/src/lib/poi.ts b/src/lib/poi.ts new file mode 100644 index 0000000..b02f5ca --- /dev/null +++ b/src/lib/poi.ts @@ -0,0 +1,92 @@ +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; +import { type PoiTypeResultData } from '@/models/v4/mapping/poiTypeResultData'; + +export interface PoiDisplayable { + Name?: string | null; + Address?: string | null; + Note?: string | null; + PoiTypeName?: string | null; +} + +export interface GroupedPoisByType { + poiTypeId: number; + poiTypeName: string; + isDestination: boolean; + color: string; + imagePath: string; + marker: string; + pois: PoiResultData[]; +} + +export interface PoiSelectOption { + value: string; + label: string; + poiTypeName: string; +} + +export const NO_DESTINATION_POI_VALUE = 'none'; + +const getDisplayValue = (value?: string | null) => value?.trim() ?? ''; + +export const getPoiDisplayName = (poi: PoiDisplayable) => { + return getDisplayValue(poi.Name) || getDisplayValue(poi.Address) || getDisplayValue(poi.Note) || getDisplayValue(poi.PoiTypeName); +}; + +export const getPoiSelectionLabel = (poi: PoiDisplayable) => { + const name = getDisplayValue(poi.Name); + const address = getDisplayValue(poi.Address); + + if (name && address) { + return `${name} - ${address}`; + } + + return getPoiDisplayName(poi); +}; + +export const groupPoisByType = (pois: PoiResultData[], poiTypes: PoiTypeResultData[] = []) => { + const poiTypesById = new Map(poiTypes.map((poiType) => [poiType.PoiTypeId, poiType])); + const groupedPois = new Map(); + + for (const poi of pois) { + const poiType = poiTypesById.get(poi.PoiTypeId); + const existingGroup = groupedPois.get(poi.PoiTypeId); + + if (existingGroup) { + existingGroup.pois.push(poi); + continue; + } + + groupedPois.set(poi.PoiTypeId, { + poiTypeId: poi.PoiTypeId, + poiTypeName: poi.PoiTypeName || poiType?.Name || '', + isDestination: poi.IsDestination || poiType?.IsDestination || false, + color: poi.Color || poiType?.Color || '', + imagePath: poi.ImagePath || poiType?.ImagePath || '', + marker: poi.Marker || poiType?.Marker || '', + pois: [poi], + }); + } + + return Array.from(groupedPois.values()).sort((left, right) => left.poiTypeName.localeCompare(right.poiTypeName)); +}; + +export const getDestinationPoiSelectOptions = (pois: PoiResultData[], poiTypes: PoiTypeResultData[] = []): PoiSelectOption[] => { + return groupPoisByType(pois, poiTypes).flatMap((group) => + [...group.pois] + .sort((left, right) => getPoiSelectionLabel(left).localeCompare(getPoiSelectionLabel(right))) + .map((poi) => ({ + value: poi.PoiId.toString(), + label: `${group.poiTypeName} - ${getPoiSelectionLabel(poi)}`, + poiTypeName: group.poiTypeName, + })) + ); +}; + +export const getDestinationPoiIdFromValue = (value?: string | null) => { + if (!value || value === NO_DESTINATION_POI_VALUE) { + return null; + } + + const parsedValue = Number(value); + return Number.isFinite(parsedValue) ? parsedValue : null; +}; diff --git a/src/lib/status-destinations.ts b/src/lib/status-destinations.ts new file mode 100644 index 0000000..f13c123 --- /dev/null +++ b/src/lib/status-destinations.ts @@ -0,0 +1,118 @@ +import { getPoiSelectionLabel } from '@/lib/poi'; +import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { type GroupResultData } from '@/models/v4/groups/groupsResultData'; +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; +import { CALL_DESTINATION_DETAIL_TYPES, CustomStateDetailTypes, POI_DESTINATION_DETAIL_TYPES, STATION_DESTINATION_DETAIL_TYPES } from '@/models/v4/statuses/customStateDetailTypes'; +import { DestinationEntityTypes } from '@/models/v4/statuses/destinationEntityTypes'; + +export type StatusDestinationType = 'none' | 'call' | 'station' | 'poi'; +export type StatusDestinationTab = 'calls' | 'stations' | 'pois'; + +export interface StatusDestinationPayload { + respondingTo: string; + respondingToType: DestinationEntityTypes | null; + eventId: string; +} + +// Pre-computed set of valid CustomStateDetailTypes numeric values used to +// guard against unknown future enum members arriving from the server. +const KNOWN_DETAIL_TYPES = new Set((Object.values(CustomStateDetailTypes) as (string | number)[]).filter((v): v is number => typeof v === 'number')); + +const toDetailType = (detail?: number | null): CustomStateDetailTypes => { + if (detail == null) { + return CustomStateDetailTypes.None; + } + return KNOWN_DETAIL_TYPES.has(detail) ? (detail as CustomStateDetailTypes) : CustomStateDetailTypes.None; +}; + +export const isDestinationRequiredForDetail = (detail?: number | null) => { + return toDetailType(detail) !== CustomStateDetailTypes.None; +}; + +export const areCallsAllowedForDetail = (detail?: number | null) => { + return CALL_DESTINATION_DETAIL_TYPES.includes(toDetailType(detail)); +}; + +export const areStationsAllowedForDetail = (detail?: number | null) => { + return STATION_DESTINATION_DETAIL_TYPES.includes(toDetailType(detail)); +}; + +export const arePoisAllowedForDetail = (detail?: number | null) => { + return POI_DESTINATION_DETAIL_TYPES.includes(toDetailType(detail)); +}; + +export const arePoisAllowedForStatus = (detail?: number | null) => { + if (arePoisAllowedForDetail(detail)) { + return true; + } + + // Some departments still expose destination-capable personnel statuses using the + // older call/station Detail values even though POIs are valid destinations there. + return areCallsAllowedForDetail(detail) || areStationsAllowedForDetail(detail); +}; + +export const getAllowedDestinationTabsForDetail = (detail?: number | null): StatusDestinationTab[] => { + const allowedTabs: StatusDestinationTab[] = []; + + if (areCallsAllowedForDetail(detail)) { + allowedTabs.push('calls'); + } + + if (areStationsAllowedForDetail(detail)) { + allowedTabs.push('stations'); + } + + if (arePoisAllowedForDetail(detail)) { + allowedTabs.push('pois'); + } + + return allowedTabs; +}; + +export const getDefaultDestinationTabForDetail = (detail?: number | null): StatusDestinationTab => { + return getAllowedDestinationTabsForDetail(detail)[0] ?? 'calls'; +}; + +export const getCallDestinationDisplay = (call: CallResultData) => { + const callNumber = call.Number?.trim() ?? ''; + const callName = call.Name?.trim() ?? ''; + + if (callNumber && callName) { + return `${callNumber} - ${callName}`; + } + + return callNumber || callName || call.Address?.trim() || ''; +}; + +export const getStationDestinationDisplay = (group: GroupResultData) => { + return group.Name?.trim() || group.Address?.trim() || ''; +}; + +export const getPoiDestinationDisplay = (poi: PoiResultData) => { + const poiLabel = getPoiSelectionLabel(poi); + return poi.PoiTypeName ? `${poi.PoiTypeName} - ${poiLabel}` : poiLabel; +}; + +export const getNoneDestinationPayload = (): StatusDestinationPayload => ({ + respondingTo: '', + respondingToType: null, + eventId: '', +}); + +export const getCallDestinationPayload = (call: CallResultData): StatusDestinationPayload => ({ + respondingTo: call.CallId, + respondingToType: DestinationEntityTypes.Call, + eventId: call.CallId, +}); + +export const getStationDestinationPayload = (group: GroupResultData): StatusDestinationPayload => ({ + respondingTo: group.GroupId, + respondingToType: DestinationEntityTypes.Station, + eventId: group.GroupId, +}); + +export const getPoiDestinationPayload = (poi: PoiResultData): StatusDestinationPayload => ({ + respondingTo: poi.PoiId.toString(), + respondingToType: DestinationEntityTypes.Poi, + eventId: '', +}); diff --git a/src/models/offline-queue/queued-event.ts b/src/models/offline-queue/queued-event.ts index 82c0b8b..dd4df99 100644 --- a/src/models/offline-queue/queued-event.ts +++ b/src/models/offline-queue/queued-event.ts @@ -33,6 +33,7 @@ export interface QueuedPersonnelStatusEvent extends Omit { statusType: string; note?: string; respondingTo?: string; + respondingToType?: number | null; timestamp: string; timestampUtc: string; latitude?: string; @@ -53,8 +54,10 @@ export interface QueuedUnitStatusEvent extends Omit { statusType: string; note?: string; respondingTo?: string; + respondingToType?: number | null; timestamp: string; timestampUtc: string; + eventId?: string; roles?: { roleId: string; userId: string; diff --git a/src/models/v4/calls/callResultData.ts b/src/models/v4/calls/callResultData.ts index 71dd248..dfc56d0 100644 --- a/src/models/v4/calls/callResultData.ts +++ b/src/models/v4/calls/callResultData.ts @@ -5,6 +5,13 @@ export class CallResultData { public Nature: string = ''; public Note: string = ''; public Address: string = ''; + public DestinationPoiId?: number | null; + public DestinationName?: string; + public DestinationAddress?: string; + public DestinationTypeName?: string; + public DestinationPoiTypeId?: number | null; + public DestinationLatitude?: number | null; + public DestinationLongitude?: number | null; public Geolocation: string = ''; public LoggedOn: string = ''; public State: string = ''; diff --git a/src/models/v4/dispatch/getSetUnitStateResultData.ts b/src/models/v4/dispatch/getSetUnitStateResultData.ts index 3e22f5a..480b6c0 100644 --- a/src/models/v4/dispatch/getSetUnitStateResultData.ts +++ b/src/models/v4/dispatch/getSetUnitStateResultData.ts @@ -1,11 +1,15 @@ import { type CallResultData } from '../calls/callResultData'; import { type CustomStatusResultData } from '../customStatuses/customStatusResultData'; import { type GroupResultData } from '../groups/groupsResultData'; +import { type PoiResultData } from '../mapping/poiResultData'; +import { type PoiTypeResultData } from '../mapping/poiTypeResultData'; export class GetSetUnitStateResultData { public UnitId: string = ''; public UnitName: string = ''; public Stations: GroupResultData[] = []; public Calls: CallResultData[] = []; + public DestinationPois: PoiResultData[] = []; + public PoiTypes: PoiTypeResultData[] = []; public Statuses: CustomStatusResultData[] = []; } diff --git a/src/models/v4/dispatch/newCallFormResultData.ts b/src/models/v4/dispatch/newCallFormResultData.ts index 0cf87b1..80150ed 100644 --- a/src/models/v4/dispatch/newCallFormResultData.ts +++ b/src/models/v4/dispatch/newCallFormResultData.ts @@ -2,6 +2,8 @@ import { type CallPriorityResultData } from '../callPriorities/callPriorityResul import { type CallTypeResultData } from '../callTypes/callTypeResultData'; import { type CustomStatusResultData } from '../customStatuses/customStatusResultData'; import { type GroupResultData } from '../groups/groupsResultData'; +import { type PoiResultData } from '../mapping/poiResultData'; +import { type PoiTypeResultData } from '../mapping/poiTypeResultData'; import { type PersonnelInfoResultData } from '../personnel/personnelInfoResultData'; import { type RoleResultData } from '../roles/roleResultData'; import { type UnitRoleResultData } from '../unitRoles/unitRoleResultData'; @@ -18,4 +20,6 @@ export class NewCallFormResultData { public UnitRoles: UnitRoleResultData[] = []; public Priorities: CallPriorityResultData[] = []; public CallTypes: CallTypeResultData[] = []; + public PoiTypes: PoiTypeResultData[] = []; + public DestinationPois: PoiResultData[] = []; } diff --git a/src/models/v4/groups/groupsResultData.ts b/src/models/v4/groups/groupsResultData.ts index 1dcb3a6..94d269c 100644 --- a/src/models/v4/groups/groupsResultData.ts +++ b/src/models/v4/groups/groupsResultData.ts @@ -1,6 +1,6 @@ export class GroupResultData { public GroupId: string = ''; - public TypeId: number = 0; + public TypeId: number | string = ''; public Name: string = ''; public Address: string = ''; diff --git a/src/models/v4/mapping/getMapDataAndMarkersData.ts b/src/models/v4/mapping/getMapDataAndMarkersData.ts index cf3ecb2..5ccb770 100644 --- a/src/models/v4/mapping/getMapDataAndMarkersData.ts +++ b/src/models/v4/mapping/getMapDataAndMarkersData.ts @@ -1,8 +1,11 @@ +import { type PoiLayerData } from './poiLayerData'; + export class MapDataAndMarkersData { public CenterLat: string = ''; public CenterLon: string = ''; public ZoomLevel: string = ''; public MapMakerInfos: MapMakerInfoData[] = []; + public PoiLayers: PoiLayerData[] = []; } export class MapMakerInfoData { @@ -10,9 +13,16 @@ export class MapMakerInfoData { public Longitude: number = 0; public Latitude: number = 0; public Title: string = ''; - public zIndex: string = ''; + public zIndex: number | string = ''; public ImagePath: string = ''; public InfoWindowContent: string = ''; public Color: string = ''; public Type: number = 0; + public Marker?: string; + public PoiTypeId?: number | null; + public PoiTypeName?: string; + public Address?: string; + public Note?: string; + public LayerId?: string; + public LayerName?: string; } diff --git a/src/models/v4/mapping/poiLayerData.ts b/src/models/v4/mapping/poiLayerData.ts new file mode 100644 index 0000000..ae72c3b --- /dev/null +++ b/src/models/v4/mapping/poiLayerData.ts @@ -0,0 +1,8 @@ +export class PoiLayerData { + public PoiTypeId: number = 0; + public Name: string = ''; + public Color: string = ''; + public ImagePath: string = ''; + public Marker: string = ''; + public IsDestination: boolean = false; +} diff --git a/src/models/v4/mapping/poiResult.ts b/src/models/v4/mapping/poiResult.ts new file mode 100644 index 0000000..15d2059 --- /dev/null +++ b/src/models/v4/mapping/poiResult.ts @@ -0,0 +1,6 @@ +import { BaseV4Request } from '../baseV4Request'; +import { PoiResultData } from './poiResultData'; + +export class PoiResult extends BaseV4Request { + public Data: PoiResultData = new PoiResultData(); +} diff --git a/src/models/v4/mapping/poiResultData.ts b/src/models/v4/mapping/poiResultData.ts new file mode 100644 index 0000000..2dad659 --- /dev/null +++ b/src/models/v4/mapping/poiResultData.ts @@ -0,0 +1,14 @@ +export class PoiResultData { + public PoiId: number = 0; + public PoiTypeId: number = 0; + public PoiTypeName: string = ''; + public Name: string = ''; + public Address: string = ''; + public Note: string = ''; + public Latitude: number = 0; + public Longitude: number = 0; + public Color: string = ''; + public ImagePath: string = ''; + public Marker: string = ''; + public IsDestination: boolean = false; +} diff --git a/src/models/v4/mapping/poiTypeResultData.ts b/src/models/v4/mapping/poiTypeResultData.ts new file mode 100644 index 0000000..e26082d --- /dev/null +++ b/src/models/v4/mapping/poiTypeResultData.ts @@ -0,0 +1,8 @@ +export class PoiTypeResultData { + public PoiTypeId: number = 0; + public Name: string = ''; + public Color: string = ''; + public ImagePath: string = ''; + public Marker: string = ''; + public IsDestination: boolean = false; +} diff --git a/src/models/v4/mapping/poiTypesResult.ts b/src/models/v4/mapping/poiTypesResult.ts new file mode 100644 index 0000000..ade68d5 --- /dev/null +++ b/src/models/v4/mapping/poiTypesResult.ts @@ -0,0 +1,6 @@ +import { BaseV4Request } from '../baseV4Request'; +import { type PoiTypeResultData } from './poiTypeResultData'; + +export class PoiTypesResult extends BaseV4Request { + public Data: PoiTypeResultData[] = []; +} diff --git a/src/models/v4/mapping/poisResult.ts b/src/models/v4/mapping/poisResult.ts new file mode 100644 index 0000000..91348c3 --- /dev/null +++ b/src/models/v4/mapping/poisResult.ts @@ -0,0 +1,6 @@ +import { BaseV4Request } from '../baseV4Request'; +import { type PoiResultData } from './poiResultData'; + +export class PoisResult extends BaseV4Request { + public Data: PoiResultData[] = []; +} diff --git a/src/models/v4/personnelStatuses/getCurrentStatusResultData.ts b/src/models/v4/personnelStatuses/getCurrentStatusResultData.ts index 3ee0447..1e6e12f 100644 --- a/src/models/v4/personnelStatuses/getCurrentStatusResultData.ts +++ b/src/models/v4/personnelStatuses/getCurrentStatusResultData.ts @@ -5,7 +5,10 @@ export class GetCurrentStatusResultData { public TimestampUtc: string = ''; public Timestamp: string = ''; public Note: string = ''; - public DestinationId: string = ''; - public DestinationType: string = ''; + public DestinationId: number | string | null = null; + public DestinationType: number | string | null = null; + public DestinationName?: string; + public DestinationAddress?: string; + public DestinationTypeName?: string; public GeoLocationData: string = ''; } diff --git a/src/models/v4/personnelStatuses/savePersonStatusInput.ts b/src/models/v4/personnelStatuses/savePersonStatusInput.ts index ea21847..37fc499 100644 --- a/src/models/v4/personnelStatuses/savePersonStatusInput.ts +++ b/src/models/v4/personnelStatuses/savePersonStatusInput.ts @@ -2,6 +2,7 @@ export class SavePersonStatusInput { public UserId: string = ''; public Type: string = ''; public RespondingTo: string = ''; + public RespondingToType: number | null = null; public TimestampUtc: string = ''; public Timestamp: string = ''; public Note: string = ''; diff --git a/src/models/v4/personnelStatuses/savePersonsStatusesInput.ts b/src/models/v4/personnelStatuses/savePersonsStatusesInput.ts index ad4eff1..74167ed 100644 --- a/src/models/v4/personnelStatuses/savePersonsStatusesInput.ts +++ b/src/models/v4/personnelStatuses/savePersonsStatusesInput.ts @@ -2,6 +2,7 @@ export class SavePersonsStatusesInput { public UserIds: string[] = []; public Type: string = ''; public RespondingTo: string = ''; + public RespondingToType: number | null = null; public TimestampUtc: string = ''; public Timestamp: string = ''; public Note: string = ''; diff --git a/src/models/v4/statuses/customStateDetailTypes.ts b/src/models/v4/statuses/customStateDetailTypes.ts new file mode 100644 index 0000000..d643341 --- /dev/null +++ b/src/models/v4/statuses/customStateDetailTypes.ts @@ -0,0 +1,31 @@ +export enum CustomStateDetailTypes { + None = 0, + Station = 1, + Call = 2, + CallsAndStations = 3, + Pois = 4, + CallsAndPois = 5, + StationsAndPois = 6, + CallsStationsAndPois = 7, +} + +export const CALL_DESTINATION_DETAIL_TYPES: CustomStateDetailTypes[] = [ + CustomStateDetailTypes.Call, + CustomStateDetailTypes.CallsAndStations, + CustomStateDetailTypes.CallsAndPois, + CustomStateDetailTypes.CallsStationsAndPois, +]; + +export const STATION_DESTINATION_DETAIL_TYPES: CustomStateDetailTypes[] = [ + CustomStateDetailTypes.Station, + CustomStateDetailTypes.CallsAndStations, + CustomStateDetailTypes.StationsAndPois, + CustomStateDetailTypes.CallsStationsAndPois, +]; + +export const POI_DESTINATION_DETAIL_TYPES: CustomStateDetailTypes[] = [ + CustomStateDetailTypes.Pois, + CustomStateDetailTypes.CallsAndPois, + CustomStateDetailTypes.StationsAndPois, + CustomStateDetailTypes.CallsStationsAndPois, +]; diff --git a/src/models/v4/statuses/destinationEntityTypes.ts b/src/models/v4/statuses/destinationEntityTypes.ts new file mode 100644 index 0000000..e1b9d2b --- /dev/null +++ b/src/models/v4/statuses/destinationEntityTypes.ts @@ -0,0 +1,5 @@ +export enum DestinationEntityTypes { + Station = 1, + Call = 2, + Poi = 3, +} diff --git a/src/models/v4/unitStatus/saveUnitStatusInput.ts b/src/models/v4/unitStatus/saveUnitStatusInput.ts index 03d00dd..623c795 100644 --- a/src/models/v4/unitStatus/saveUnitStatusInput.ts +++ b/src/models/v4/unitStatus/saveUnitStatusInput.ts @@ -2,6 +2,7 @@ export class SaveUnitStatusInput { public Id: string = ''; public Type: string = ''; public RespondingTo: string = ''; + public RespondingToType: number | null = null; public TimestampUtc: string = ''; public Timestamp: string = ''; public Note: string = ''; diff --git a/src/models/v4/unitStatus/unitStatusResultData.ts b/src/models/v4/unitStatus/unitStatusResultData.ts index 4672503..02d187e 100644 --- a/src/models/v4/unitStatus/unitStatusResultData.ts +++ b/src/models/v4/unitStatus/unitStatusResultData.ts @@ -1,16 +1,21 @@ export class UnitStatusResultData { + public UnitId: string = ''; public Name: string = ''; public Type: string = ''; public State: string = ''; public StateCss: string = ''; public StateStyle: string = ''; public Timestamp: string = ''; - public DestinationId: string = ''; + public TimestampUtc?: string; + public DestinationId: number | string | null = null; + public DestinationType?: number | string | null; + public DestinationName?: string; + public DestinationAddress?: string; + public DestinationTypeName?: string; public Note: string = ''; - public Latitude: string = ''; - public Longitude: string = ''; + public Latitude?: number | null; + public Longitude?: number | null; public GroupName: string = ''; - public GroupId: string = ''; + public GroupId: number | string = ''; public Eta: string = ''; - public UnitId: string = ''; } diff --git a/src/services/offline-event-manager.service.ts b/src/services/offline-event-manager.service.ts index e0eb63d..40fa31e 100644 --- a/src/services/offline-event-manager.service.ts +++ b/src/services/offline-event-manager.service.ts @@ -117,7 +117,9 @@ class OfflineEventManager { altitudeAccuracy?: string; speed?: string; heading?: string; - } + }, + respondingToType?: number | null, + eventId?: string ): string { const date = new Date(); const data = { @@ -125,8 +127,10 @@ class OfflineEventManager { statusType, note, respondingTo, + respondingToType, timestamp: date.toISOString(), timestampUtc: date.toUTCString().replace('UTC', 'GMT'), + eventId, roles, latitude: gpsData?.latitude, longitude: gpsData?.longitude, @@ -290,8 +294,10 @@ class OfflineEventManager { input.Type = event.data.statusType; input.Note = event.data.note || ''; input.RespondingTo = event.data.respondingTo || '0'; + input.RespondingToType = event.data.respondingToType ?? null; input.Timestamp = event.data.timestamp; input.TimestampUtc = event.data.timestampUtc; + input.EventId = event.data.eventId || ''; // Always set GPS coordinates (even if empty) if (event.data.latitude && event.data.longitude) { diff --git a/src/stores/poi/store.ts b/src/stores/poi/store.ts new file mode 100644 index 0000000..ab0e138 --- /dev/null +++ b/src/stores/poi/store.ts @@ -0,0 +1,119 @@ +import { create } from 'zustand'; + +import { getPoi, getPois, getPoiTypes } from '@/api/mapping/mapping'; +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; +import { type PoiTypeResultData } from '@/models/v4/mapping/poiTypeResultData'; + +interface PoiState { + poiTypes: PoiTypeResultData[]; + pois: PoiResultData[]; + poiDetailsById: Record; + hasFetchedPois: boolean; + isLoading: boolean; + isLoadingPoi: boolean; + error: string | null; + fetchPoisData: (force?: boolean) => Promise; + fetchPoiDetail: (poiId: number, force?: boolean) => Promise; + getPoiById: (poiId: number) => PoiResultData | null; + reset: () => void; +} + +const getPoiDetailsMap = (pois: PoiResultData[]) => { + return pois.reduce>((detailsById, poi) => { + detailsById[poi.PoiId] = poi; + return detailsById; + }, {}); +}; + +export const usePoiStore = create((set, get) => ({ + poiTypes: [], + pois: [], + poiDetailsById: {}, + hasFetchedPois: false, + isLoading: false, + isLoadingPoi: false, + error: null, + fetchPoisData: async (force = false) => { + const { hasFetchedPois } = get(); + if (!force && hasFetchedPois) { + return; + } + + set({ isLoading: true, error: null }); + + try { + const [poiTypesResult, poisResult] = await Promise.all([getPoiTypes(), getPois()]); + const nextPoiTypes = poiTypesResult.Data || []; + const nextPois = poisResult.Data || []; + + set({ + poiTypes: nextPoiTypes, + pois: nextPois, + poiDetailsById: getPoiDetailsMap(nextPois), + hasFetchedPois: true, + isLoading: false, + error: null, + }); + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch POIs', + }); + } + }, + fetchPoiDetail: async (poiId, force = false) => { + const cachedPoi = get().getPoiById(poiId); + if (cachedPoi && !force) { + return cachedPoi; + } + + set({ isLoadingPoi: true, error: null }); + + try { + const poiResult = await getPoi(poiId); + const poi = poiResult.Data; + + if (!poi) { + set({ isLoadingPoi: false }); + return null; + } + + set((state) => { + const hasPoiInList = state.pois.some((currentPoi) => currentPoi.PoiId === poi.PoiId); + const nextPois = hasPoiInList ? state.pois.map((currentPoi) => (currentPoi.PoiId === poi.PoiId ? poi : currentPoi)) : [...state.pois, poi]; + + return { + pois: nextPois, + poiDetailsById: { + ...state.poiDetailsById, + [poi.PoiId]: poi, + }, + isLoadingPoi: false, + error: null, + }; + }); + + return poi; + } catch (error) { + set({ + isLoadingPoi: false, + error: error instanceof Error ? error.message : 'Failed to fetch POI details', + }); + return null; + } + }, + getPoiById: (poiId) => { + const { poiDetailsById, pois } = get(); + return poiDetailsById[poiId] || pois.find((poi) => poi.PoiId === poiId) || null; + }, + reset: () => + set({ + poiTypes: [], + pois: [], + poiDetailsById: {}, + hasFetchedPois: false, + isLoading: false, + isLoadingPoi: false, + error: null, + }), +})); diff --git a/src/stores/status/__tests__/personnel-status-store.test.ts b/src/stores/status/__tests__/personnel-status-store.test.ts index c69d499..606c95a 100644 --- a/src/stores/status/__tests__/personnel-status-store.test.ts +++ b/src/stores/status/__tests__/personnel-status-store.test.ts @@ -444,10 +444,10 @@ describe('usePersonnelStatusBottomSheetStore', () => { describe('fetchGroups', () => { it('should fetch groups successfully and filter to only station groups', async () => { const mockGroups = [ - { GroupId: '1', Name: 'Station 1', Address: '100 Fire Station Rd', GroupType: 'Fire Station', TypeId: 1 }, - { GroupId: '2', Name: 'Station 2', Address: '200 Fire Station Ave', GroupType: 'Fire Station', TypeId: 1 }, - { GroupId: '3', Name: 'Response Group', Address: '', GroupType: 'Response', TypeId: 2 }, - { GroupId: '4', Name: 'Admin Group', Address: '', GroupType: 'Admin', TypeId: 3 }, + { GroupId: '1', Name: 'Station 1', Address: '100 Fire Station Rd', GroupType: 'Fire Station', TypeId: '1' }, + { GroupId: '2', Name: 'Station 2', Address: '200 Fire Station Ave', GroupType: 'Fire Station', TypeId: '1' }, + { GroupId: '3', Name: 'Response Group', Address: '', GroupType: 'Response', TypeId: '2' }, + { GroupId: '4', Name: 'Admin Group', Address: '', GroupType: 'Admin', TypeId: '3' }, ]; mockGetAllGroups.mockResolvedValue({ Data: mockGroups } as any); @@ -458,11 +458,11 @@ describe('usePersonnelStatusBottomSheetStore', () => { }); expect(mockGetAllGroups).toHaveBeenCalled(); - // Should only contain station groups (TypeId: 1) + // Should only contain station groups (TypeId: "1" from the v4 API) expect(result.current.groups).toHaveLength(2); expect(result.current.groups).toEqual([ - { GroupId: '1', Name: 'Station 1', Address: '100 Fire Station Rd', GroupType: 'Fire Station', TypeId: 1 }, - { GroupId: '2', Name: 'Station 2', Address: '200 Fire Station Ave', GroupType: 'Fire Station', TypeId: 1 }, + { GroupId: '1', Name: 'Station 1', Address: '100 Fire Station Rd', GroupType: 'Fire Station', TypeId: '1' }, + { GroupId: '2', Name: 'Station 2', Address: '200 Fire Station Ave', GroupType: 'Fire Station', TypeId: '1' }, ]); expect(result.current.isLoadingGroups).toBe(false); }); @@ -1050,6 +1050,56 @@ describe('usePersonnelStatusBottomSheetStore', () => { expect(result.current.areStationsAllowed()).toBe(true); }); + it('should correctly determine if POIs are allowed for destination-capable statuses', () => { + const { result } = renderHook(() => usePersonnelStatusBottomSheetStore()); + + const statusDetail0 = { + Id: 1, + Text: 'Available', + BColor: '#00FF00', + Type: 1, + StateId: 1, + Color: '#00FF00', + Gps: false, + Note: 0, + Detail: 0 + }; + + act(() => { + result.current.setIsOpen(true, statusDetail0 as any); + }); + + expect(result.current.arePoisAllowed()).toBe(false); + + const statusDetail1 = { ...statusDetail0, Detail: 1 }; + act(() => { + result.current.setIsOpen(true, statusDetail1 as any); + }); + + expect(result.current.arePoisAllowed()).toBe(true); + + const statusDetail2 = { ...statusDetail0, Detail: 2 }; + act(() => { + result.current.setIsOpen(true, statusDetail2 as any); + }); + + expect(result.current.arePoisAllowed()).toBe(true); + + const statusDetail3 = { ...statusDetail0, Detail: 3 }; + act(() => { + result.current.setIsOpen(true, statusDetail3 as any); + }); + + expect(result.current.arePoisAllowed()).toBe(true); + + const statusDetail4 = { ...statusDetail0, Detail: 4 }; + act(() => { + result.current.setIsOpen(true, statusDetail4 as any); + }); + + expect(result.current.arePoisAllowed()).toBe(true); + }); + it('should correctly determine GPS requirements based on Gps field', () => { const { result } = renderHook(() => usePersonnelStatusBottomSheetStore()); diff --git a/src/stores/status/personnel-status-store.ts b/src/stores/status/personnel-status-store.ts index 1f73710..de18a8c 100644 --- a/src/stores/status/personnel-status-store.ts +++ b/src/stores/status/personnel-status-store.ts @@ -1,10 +1,28 @@ import { create } from 'zustand'; import { getAllGroups } from '@/api/groups/groups'; +import { getPois, getPoiTypes } from '@/api/mapping/mapping'; import { savePersonnelStatus } from '@/api/personnel/personnelStatuses'; import { useAuthStore } from '@/lib/auth'; +import { translate } from '@/lib/i18n/utils'; +import { + areCallsAllowedForDetail, + arePoisAllowedForDetail, + arePoisAllowedForStatus, + areStationsAllowedForDetail, + getCallDestinationPayload, + getDefaultDestinationTabForDetail, + getNoneDestinationPayload, + getPoiDestinationPayload, + getStationDestinationPayload, + isDestinationRequiredForDetail, + type StatusDestinationTab, + type StatusDestinationType, +} from '@/lib/status-destinations'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { type GroupResultData } from '@/models/v4/groups/groupsResultData'; +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; +import { type PoiTypeResultData } from '@/models/v4/mapping/poiTypeResultData'; import { SavePersonStatusInput } from '@/models/v4/personnelStatuses/savePersonStatusInput'; import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; import { offlineQueueProcessor } from '@/services/offline-queue-processor'; @@ -12,17 +30,30 @@ import { useLocationStore } from '@/stores/app/location-store'; import { useHomeStore } from '@/stores/home/home-store'; import { useToastStore } from '@/stores/toast/store'; -import { useCallsStore } from '../calls/store'; +export type PersonnelStatusStep = 'select-status' | 'select-responding-to' | 'add-note' | 'confirm'; +export type ResponseTab = StatusDestinationTab; +export type ResponseType = StatusDestinationType; -export type PersonnelStatusStep = 'select-responding-to' | 'add-note' | 'confirm'; -export type ResponseTab = 'calls' | 'stations'; -export type ResponseType = 'none' | 'call' | 'station'; +interface PersonnelStatusOpenOptions { + preselectedPoi?: PoiResultData | null; +} + +interface DestinationSelectionState { + selectedCall: CallResultData | null; + selectedGroup: GroupResultData | null; + selectedPoi: PoiResultData | null; + responseType: ResponseType; + selectedTab: ResponseTab; + respondingTo: string; +} interface PersonnelStatusBottomSheetStore { isOpen: boolean; + requiresStatusSelection: boolean; currentStep: PersonnelStatusStep; selectedCall: CallResultData | null; selectedGroup: GroupResultData | null; + selectedPoi: PoiResultData | null; selectedStatus: StatusesResultData | null; responseType: ResponseType; selectedTab: ResponseTab; @@ -31,33 +62,104 @@ interface PersonnelStatusBottomSheetStore { isLoading: boolean; groups: GroupResultData[]; isLoadingGroups: boolean; - setIsOpen: (isOpen: boolean, status?: StatusesResultData) => void; + pois: PoiResultData[]; + poiTypes: PoiTypeResultData[]; + isLoadingPois: boolean; + poisError: string | null; + setIsOpen: (isOpen: boolean, status?: StatusesResultData, options?: PersonnelStatusOpenOptions) => void; setCurrentStep: (step: PersonnelStatusStep) => void; setSelectedCall: (call: CallResultData | null) => void; setSelectedGroup: (group: GroupResultData | null) => void; + setSelectedPoi: (poi: PoiResultData | null) => void; setResponseType: (type: ResponseType) => void; + setSelectedStatus: (status: StatusesResultData | null) => void; setSelectedTab: (tab: ResponseTab) => void; setNote: (note: string) => void; setRespondingTo: (respondingTo: string) => void; setIsLoading: (isLoading: boolean) => void; fetchGroups: () => Promise; + fetchDestinationPois: () => Promise; nextStep: () => void; goToNextStep: () => void; previousStep: () => void; submitStatus: () => Promise; reset: () => void; - // Helper methods for Detail-based logic isDestinationRequired: () => boolean; areCallsAllowed: () => boolean; areStationsAllowed: () => boolean; + arePoisAllowed: () => boolean; getRequiredGpsAccuracy: () => boolean; } +const getTranslatedMessage = (key: Parameters[0], fallback: string) => { + const message = translate(key); + return typeof message === 'string' && message.length > 0 && message !== key ? message : fallback; +}; + +const isStationGroup = (group: GroupResultData) => { + return `${group.TypeId ?? ''}` === '1'; +}; + +const getClearedDestinationState = (selectedTab: ResponseTab = 'calls'): DestinationSelectionState => ({ + selectedCall: null, + selectedGroup: null, + selectedPoi: null, + responseType: 'none', + selectedTab, + respondingTo: '', +}); + +const getDestinationStateForStatus = (selectedStatus: StatusesResultData | null, destinationState: DestinationSelectionState): DestinationSelectionState => { + if (!selectedStatus) { + return { + ...destinationState, + selectedTab: destinationState.selectedPoi ? 'pois' : destinationState.selectedTab, + }; + } + + if (destinationState.selectedPoi && arePoisAllowedForStatus(selectedStatus.Detail)) { + return { + selectedCall: null, + selectedGroup: null, + selectedPoi: destinationState.selectedPoi, + responseType: 'poi', + selectedTab: 'pois', + respondingTo: destinationState.selectedPoi.PoiId.toString(), + }; + } + + if (destinationState.selectedCall && areCallsAllowedForDetail(selectedStatus.Detail)) { + return { + selectedCall: destinationState.selectedCall, + selectedGroup: null, + selectedPoi: null, + responseType: 'call', + selectedTab: 'calls', + respondingTo: destinationState.selectedCall.CallId, + }; + } + + if (destinationState.selectedGroup && areStationsAllowedForDetail(selectedStatus.Detail)) { + return { + selectedCall: null, + selectedGroup: destinationState.selectedGroup, + selectedPoi: null, + responseType: 'station', + selectedTab: 'stations', + respondingTo: destinationState.selectedGroup.GroupId, + }; + } + + return getClearedDestinationState(getDefaultDestinationTabForDetail(selectedStatus.Detail)); +}; + export const usePersonnelStatusBottomSheetStore = create((set, get) => ({ isOpen: false, + requiresStatusSelection: false, currentStep: 'select-responding-to', selectedCall: null, selectedGroup: null, + selectedPoi: null, selectedStatus: null, responseType: 'none', selectedTab: 'calls', @@ -66,11 +168,38 @@ export const usePersonnelStatusBottomSheetStore = create { + pois: [], + poiTypes: [], + isLoadingPois: false, + poisError: null, + setIsOpen: (isOpen, status, options) => { + if (!isOpen) { + set({ isOpen: false }); + return; + } + + const preselectedPoi = options?.preselectedPoi ?? null; + const destinationState = getDestinationStateForStatus( + status || null, + preselectedPoi + ? { + selectedCall: null, + selectedGroup: null, + selectedPoi: preselectedPoi, + responseType: 'poi', + selectedTab: 'pois', + respondingTo: preselectedPoi.PoiId.toString(), + } + : getClearedDestinationState() + ); + set({ - isOpen, + isOpen: true, + requiresStatusSelection: !status && preselectedPoi != null, + currentStep: status || preselectedPoi == null ? 'select-responding-to' : 'select-status', selectedStatus: status || null, - currentStep: status ? 'select-responding-to' : 'select-responding-to', + note: '', + ...destinationState, }); }, setCurrentStep: (step) => set({ currentStep: step }), @@ -78,7 +207,9 @@ export const usePersonnelStatusBottomSheetStore = create { + set({ + selectedPoi: poi, + selectedCall: null, + selectedGroup: null, + responseType: poi ? 'poi' : 'none', + selectedTab: 'pois', + respondingTo: poi ? poi.PoiId.toString() : '', + }); + }, setResponseType: (type) => { if (type === 'none') { - set({ - responseType: type, - selectedCall: null, - selectedGroup: null, - respondingTo: '', - }); - } else { - set({ responseType: type }); + set(getClearedDestinationState(get().selectedTab)); + return; } + + set({ + responseType: type, + selectedTab: type === 'call' ? 'calls' : type === 'station' ? 'stations' : 'pois', + }); + }, + setSelectedStatus: (selectedStatus) => { + const { selectedCall, selectedGroup, selectedPoi, responseType, selectedTab } = get(); + const nextDestinationState = getDestinationStateForStatus(selectedStatus, { + selectedCall, + selectedGroup, + selectedPoi, + responseType, + selectedTab, + respondingTo: '', + }); + + set({ + selectedStatus, + ...nextDestinationState, + }); + }, + setSelectedTab: (selectedTab) => { + const { selectedStatus } = get(); + + if (selectedStatus) { + const allowedTabs = [ + ...(areCallsAllowedForDetail(selectedStatus.Detail) ? (['calls'] as ResponseTab[]) : []), + ...(areStationsAllowedForDetail(selectedStatus.Detail) ? (['stations'] as ResponseTab[]) : []), + ...(arePoisAllowedForDetail(selectedStatus.Detail) ? (['pois'] as ResponseTab[]) : []), + ]; + + if (allowedTabs.length > 0 && !allowedTabs.includes(selectedTab)) { + return; + } + } + + set({ selectedTab }); }, - setSelectedTab: (tab) => set({ selectedTab: tab }), setNote: (note) => set({ note }), setRespondingTo: (respondingTo) => set({ respondingTo }), setIsLoading: (isLoading) => set({ isLoading }), fetchGroups: async () => { set({ isLoadingGroups: true }); + try { const groupsResult = await getAllGroups(); - // Filter to only include Station groups (TypeId === 1) - const stationGroups = (groupsResult.Data || []).filter((group) => group.TypeId === 1); + const stationGroups = (groupsResult.Data || []).filter(isStationGroup); set({ groups: stationGroups, isLoadingGroups: false }); } catch (error) { set({ groups: [], isLoadingGroups: false }); } }, + fetchDestinationPois: async () => { + set({ isLoadingPois: true }); + + try { + const [poiTypesResult, poisResult] = await Promise.all([getPoiTypes(), getPois({ destinationOnly: true })]); + set({ + poiTypes: poiTypesResult.Data || [], + pois: poisResult.Data || [], + isLoadingPois: false, + poisError: null, + }); + } catch (error) { + set({ + isLoadingPois: false, + poisError: error instanceof Error ? error.message : 'Failed to fetch destination POIs', + }); + } + }, nextStep: () => { const { currentStep } = get(); + switch (currentStep) { + case 'select-status': + set({ currentStep: 'select-responding-to' }); + break; case 'select-responding-to': - // "No Destination" is always valid regardless of status Detail value - // User can always proceed with any selection (call, station, or none) set({ currentStep: 'add-note' }); break; case 'add-note': @@ -134,10 +328,13 @@ export const usePersonnelStatusBottomSheetStore = create { - const { currentStep } = get(); + const { currentStep, requiresStatusSelection } = get(); + switch (currentStep) { + case 'select-responding-to': + set({ currentStep: requiresStatusSelection ? 'select-status' : 'select-responding-to' }); + break; case 'add-note': - // Always go back to select-responding-to step (even if skipped in some cases) set({ currentStep: 'select-responding-to' }); break; case 'confirm': @@ -146,70 +343,74 @@ export const usePersonnelStatusBottomSheetStore = create { - const { selectedStatus, note, respondingTo, selectedCall, selectedGroup, responseType, getRequiredGpsAccuracy } = get(); + const { selectedStatus, note, selectedCall, selectedGroup, selectedPoi, responseType, respondingTo, getRequiredGpsAccuracy } = get(); const showToast = useToastStore.getState().showToast; const { userId } = useAuthStore.getState(); const { fetchCurrentUserInfo } = useHomeStore.getState(); const locationState = useLocationStore.getState(); if (!userId || !selectedStatus) { - showToast('error', 'Missing required information'); + showToast('error', getTranslatedMessage('personnel.status.missing_required_info', 'Missing required information')); return; } - // Check GPS requirements + if (isDestinationRequiredForDetail(selectedStatus.Detail)) { + const hasDestination = (responseType === 'call' && selectedCall != null) || (responseType === 'station' && selectedGroup != null) || (responseType === 'poi' && selectedPoi != null); + + if (!hasDestination) { + showToast('error', getTranslatedMessage('personnel.status.destination_required', 'A destination is required for this status')); + return; + } + } + const requiresGps = getRequiredGpsAccuracy(); - if (requiresGps && (!locationState.latitude || !locationState.longitude)) { - showToast('error', 'GPS location is required for this status but not available'); + if (requiresGps && (locationState.latitude == null || locationState.longitude == null)) { + showToast('error', getTranslatedMessage('personnel.status.gps_required', 'GPS location is required for this status but not available')); return; } set({ isLoading: true }); + try { const status = new SavePersonStatusInput(); const date = new Date(); + const destinationPayload = + responseType === 'call' && selectedCall + ? getCallDestinationPayload(selectedCall) + : responseType === 'station' && selectedGroup + ? getStationDestinationPayload(selectedGroup) + : responseType === 'poi' && selectedPoi + ? getPoiDestinationPayload(selectedPoi) + : getNoneDestinationPayload(); status.UserId = userId; status.Type = selectedStatus.Id.toString(); status.Timestamp = date.toISOString(); status.TimestampUtc = date.toUTCString().replace('UTC', 'GMT'); status.Note = note; - status.RespondingTo = respondingTo; - - // Always include GPS coordinates if available (regardless of requirement) - status.Latitude = locationState.latitude ? locationState.latitude.toString() : ''; - status.Longitude = locationState.longitude ? locationState.longitude.toString() : ''; - status.Accuracy = locationState.accuracy ? locationState.accuracy.toString() : ''; - status.Altitude = locationState.altitude ? locationState.altitude.toString() : ''; + status.RespondingTo = respondingTo || destinationPayload.respondingTo; + status.RespondingToType = destinationPayload.respondingToType; + status.EventId = destinationPayload.eventId; + status.Latitude = locationState.latitude != null ? locationState.latitude.toString() : ''; + status.Longitude = locationState.longitude != null ? locationState.longitude.toString() : ''; + status.Accuracy = locationState.accuracy != null ? locationState.accuracy.toString() : ''; + status.Altitude = locationState.altitude != null ? locationState.altitude.toString() : ''; status.AltitudeAccuracy = ''; - status.Speed = locationState.speed ? locationState.speed.toString() : ''; - status.Heading = locationState.heading ? locationState.heading.toString() : ''; - - // Set EventId based on response type - if (responseType === 'call' && selectedCall) { - status.EventId = selectedCall.CallId; - } else if (responseType === 'station' && selectedGroup) { - status.EventId = selectedGroup.GroupId; - } else { - status.EventId = ''; - } + status.Speed = locationState.speed != null ? locationState.speed.toString() : ''; + status.Heading = locationState.heading != null ? locationState.heading.toString() : ''; try { - // Try to submit directly first await savePersonnelStatus(status); await fetchCurrentUserInfo(); - showToast('success', 'Status updated successfully'); + showToast('success', getTranslatedMessage('home.status.updated_successfully', 'Status updated successfully')); get().reset(); } catch (error) { - // If direct submission fails, add to offline queue - console.warn('Direct status submission failed, adding to offline queue:', error); - offlineQueueProcessor.addPersonnelStatusToQueue(status); - showToast('info', 'Status saved offline and will be submitted when connection is restored'); + showToast('info', getTranslatedMessage('personnel.status.saved_offline', 'Status saved offline and will be submitted when connection is restored')); get().reset(); } } catch (error) { - showToast('error', 'Failed to update status'); + showToast('error', getTranslatedMessage('home.status.update_failed', 'Failed to update status')); } finally { set({ isLoading: false }); } @@ -217,45 +418,32 @@ export const usePersonnelStatusBottomSheetStore = create set({ isOpen: false, + requiresStatusSelection: false, currentStep: 'select-responding-to', - selectedCall: null, - selectedGroup: null, selectedStatus: null, - responseType: 'none', - selectedTab: 'calls', note: '', - respondingTo: '', isLoading: false, groups: [], isLoadingGroups: false, + pois: [], + poiTypes: [], + isLoadingPois: false, + poisError: null, + ...getClearedDestinationState(), }), - - // Helper methods for Detail-based logic isDestinationRequired: () => { - const { selectedStatus } = get(); - if (!selectedStatus) return false; - // Detail: 0 = No destination needed, 1 = Station only, 2 = Call only, 3 = Both - return selectedStatus.Detail > 0; + return isDestinationRequiredForDetail(get().selectedStatus?.Detail); }, - areCallsAllowed: () => { - const { selectedStatus } = get(); - if (!selectedStatus) return false; - // Detail: 2 = Call only, 3 = Both - return selectedStatus.Detail === 2 || selectedStatus.Detail === 3; + return areCallsAllowedForDetail(get().selectedStatus?.Detail); }, - areStationsAllowed: () => { - const { selectedStatus } = get(); - if (!selectedStatus) return false; - // Detail: 1 = Station only, 3 = Both - return selectedStatus.Detail === 1 || selectedStatus.Detail === 3; + return areStationsAllowedForDetail(get().selectedStatus?.Detail); + }, + arePoisAllowed: () => { + return arePoisAllowedForStatus(get().selectedStatus?.Detail); }, - getRequiredGpsAccuracy: () => { - const { selectedStatus } = get(); - if (!selectedStatus) return false; - // Use the Gps field to determine if GPS is required - return selectedStatus.Gps; + return get().selectedStatus?.Gps ?? false; }, })); diff --git a/src/translations/ar.json b/src/translations/ar.json index 8c500b2..93ece3b 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -180,6 +180,7 @@ "contact_info": "معلومات الاتصال", "contact_name": "اسم جهة الاتصال", "contact_phone": "الهاتف", + "destination": "الوجهة", "edit_call": "تعديل المكالمة", "external_id": "المعرف الخارجي", "failed_to_open_maps": "فشل في فتح تطبيق الخرائط", @@ -219,6 +220,7 @@ "notes": "ملاحظات", "priority": "الأولوية", "reference_id": "معرف المرجع", + "route_to_destination": "المسار إلى الوجهة", "status": "الحالة", "tabs": { "contact": "جهة الاتصال", @@ -269,6 +271,9 @@ "description": "الوصف", "description_placeholder": "أدخل وصف المكالمة", "deselect": "إلغاء التحديد", + "destination": "الوجهة", + "destination_load_error": "تعذر تحميل نقاط الاهتمام الخاصة بالوجهة", + "destination_placeholder": "اختر وجهة", "directions": "الاتجاهات", "dispatch_to": "إرسال إلى", "dispatch_to_everyone": "إرسال إلى جميع الموظفين المتاحين", @@ -399,6 +404,7 @@ "no_location": "لا يوجد موقع متاح", "no_results_found": "لم يتم العثور على نتائج", "no_unit_selected": "لم يتم اختيار وحدة", + "none": "لا شيء", "of": "من", "ok": "موافق", "optional": "اختياري", @@ -657,10 +663,16 @@ "call_set_as_current": "تم تعيين المكالمة كمكالمة حالية", "failed_to_open_maps": "فشل في فتح تطبيق الخرائط", "failed_to_set_current_call": "فشل في تعيين المكالمة كمكالمة حالية", + "loading_markers": "جارٍ تحميل علامات الخريطة...", "no_location_for_routing": "لا توجد بيانات موقع متاحة للتوجيه", + "openSideMenu": "افتح القائمة الجانبية", "pin_color": "لون الدبوس", "recenter_map": "إعادة توسيط الخريطة", "set_as_current_call": "تعيين كمكالمة حالية", + "tabs": { + "map": "الخريطة", + "pois": "نقاط الاهتمام" + }, "view_call_details": "عرض تفاصيل المكالمة" }, "maps": { @@ -793,15 +805,21 @@ "calls_tab": "المكالمات", "confirm_status": "تأكيد الحالة: {{status}}", "custom_responding_to": "الاستجابة المخصصة إلى", + "destination_required": "هذه الحالة تتطلب وجهة", "general_status": "تحديث الحالة العامة", + "gps_required": "موقع GPS مطلوب لهذه الحالة", + "loading_pois": "جارٍ تحميل نقاط الاهتمام...", "loading_stations": "تحميل المحطات...", + "missing_required_info": "المعلومات المطلوبة مفقودة", "no_destination": "لا وجهة", "no_stations_available": "لا توجد محطات متاحة", "note": "ملاحظة", "note_placeholder": "أدخل ملاحظة (اختيارية)...", + "pois_tab": "نقاط الاهتمام", "responding_to": "الاستجابة إلى", "responding_to_placeholder": "أدخل ما تستجيب إليه...", "review_and_confirm": "مراجعة وتأكيد", + "saved_offline": "تم حفظ الحالة دون اتصال وسيتم إرسالها عند استعادة الاتصال", "select_call_to_respond_to": "اختر المكالمة للرد عليها", "select_destination": "اختر الوجهة", "select_responding_to": "تعيين الحالة: {{status}}", @@ -812,6 +830,35 @@ "status": "الحالة" } }, + "poi": { + "address_label": "العنوان", + "coordinates_label": "الإحداثيات", + "destination_enabled": "يمكن استخدامها كوجهة", + "empty_description": "لا توجد نقاط اهتمام تطابق عامل التصفية الحالي.", + "empty_title": "لم يتم العثور على نقاط اهتمام", + "filter_all_types": "كل الأنواع", + "filter_label": "تصفية", + "invalid_description": "مطلوب معرّف نقطة اهتمام لعرض التفاصيل.", + "invalid_title": "نقطة اهتمام غير صالحة", + "list_description": "تصفح نقاط الاهتمام الخاصة بالقسم، وقم بالتصفية حسب النوع، ثم ارجع إلى الخريطة.", + "load_error_title": "تعذر تحميل نقطة الاهتمام", + "loading": "جارٍ تحميل نقاط الاهتمام...", + "loading_detail": "جارٍ تحميل تفاصيل نقطة الاهتمام...", + "not_found": "تعذر العثور على نقطة الاهتمام", + "note_label": "ملاحظة", + "results_count": "{{count}} نقطة اهتمام", + "results_count_plural": "{{count}} نقاط اهتمام", + "route_error": "تعذر فتح تطبيق الخرائط", + "set_status_destination": "استخدامها كوجهة للحالة", + "sort_label": "ترتيب", + "sort_name": "الاسم", + "sort_type": "النوع", + "title": "نقطة اهتمام", + "type_label": "النوع", + "view_details": "عرض التفاصيل", + "view_on_map": "عرض على الخريطة", + "view_on_map_hint": "أبقِ الخريطة مفتوحة لرؤية موقعك ونقطة الاهتمام المحددة معًا." + }, "protocols": { "details": { "close": "إغلاق", diff --git a/src/translations/de.json b/src/translations/de.json index a1da08f..7056af5 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -180,6 +180,7 @@ "contact_info": "Kontaktdaten", "contact_name": "Kontaktname", "contact_phone": "Telefon", + "destination": "Ziel", "edit_call": "Einsatz bearbeiten", "external_id": "Externe ID", "failed_to_open_maps": "Kartenanwendung konnte nicht geöffnet werden", @@ -219,6 +220,7 @@ "notes": "Notizen", "priority": "Priorität", "reference_id": "Referenz-ID", + "route_to_destination": "Route zum Ziel", "status": "Status", "tabs": { "contact": "Kontakt", @@ -269,6 +271,9 @@ "description": "Beschreibung", "description_placeholder": "Einsatzbeschreibung eingeben", "deselect": "Abwählen", + "destination": "Ziel", + "destination_load_error": "Ziel-POIs konnten nicht geladen werden", + "destination_placeholder": "Ziel auswählen", "directions": "Wegbeschreibung", "dispatch_to": "Alarmieren an", "dispatch_to_everyone": "An alle verfügbaren Kräfte alarmieren", @@ -399,6 +404,7 @@ "no_location": "Kein Standort verfügbar", "no_results_found": "Keine Ergebnisse gefunden", "no_unit_selected": "Keine Einheit ausgewählt", + "none": "Keine", "of": "von", "ok": "OK", "optional": "Optional", @@ -657,10 +663,16 @@ "call_set_as_current": "Einsatz als aktuellen Einsatz festgelegt", "failed_to_open_maps": "Kartenanwendung konnte nicht geöffnet werden", "failed_to_set_current_call": "Einsatz konnte nicht als aktueller Einsatz festgelegt werden", + "loading_markers": "Lade Kartenmarkierungen...", "no_location_for_routing": "Keine Standortdaten für die Navigation verfügbar", + "openSideMenu": "Seitenmenü öffnen", "pin_color": "Stecknadel-Farbe", "recenter_map": "Karte neu zentrieren", "set_as_current_call": "Als aktuellen Einsatz festlegen", + "tabs": { + "map": "Karte", + "pois": "POIs" + }, "view_call_details": "Einsatzdetails anzeigen" }, "maps": { @@ -793,15 +805,21 @@ "calls_tab": "Einsätze", "confirm_status": "Status bestätigen: {{status}}", "custom_responding_to": "Benutzerdefinierte Reaktion auf", + "destination_required": "Für diesen Status ist ein Ziel erforderlich", "general_status": "Allgemeine Statusaktualisierung", + "gps_required": "Für diesen Status ist ein GPS-Standort erforderlich", + "loading_pois": "Lade POIs...", "loading_stations": "Stationen werden geladen...", + "missing_required_info": "Erforderliche Informationen fehlen", "no_destination": "Kein Ziel", "no_stations_available": "Keine Stationen verfügbar", "note": "Notiz", "note_placeholder": "Notiz eingeben (optional)...", + "pois_tab": "POIs", "responding_to": "Reagiert auf", "responding_to_placeholder": "Eingeben, worauf Sie reagieren...", "review_and_confirm": "Überprüfen und bestätigen", + "saved_offline": "Status wurde offline gespeichert und wird nach Wiederherstellung der Verbindung gesendet", "select_call_to_respond_to": "Einsatz zum Reagieren auswählen", "select_destination": "Ziel auswählen", "select_responding_to": "Status festlegen: {{status}}", @@ -812,6 +830,35 @@ "status": "Status" } }, + "poi": { + "address_label": "Adresse", + "coordinates_label": "Koordinaten", + "destination_enabled": "Als Ziel verfügbar", + "empty_description": "Keine POIs entsprechen dem aktuellen Filter.", + "empty_title": "Keine POIs gefunden", + "filter_all_types": "Alle Typen", + "filter_label": "Filter", + "invalid_description": "Zum Anzeigen der POI-Details ist eine POI-ID erforderlich.", + "invalid_title": "Ungültiger POI", + "list_description": "Durchsuchen Sie die Abteilungs-POIs, filtern Sie nach Typ und kehren Sie zur Karte zurück.", + "load_error_title": "POI konnte nicht geladen werden", + "loading": "Lade POIs...", + "loading_detail": "Lade POI-Details...", + "not_found": "POI nicht gefunden", + "note_label": "Hinweis", + "results_count": "{{count}} POI", + "results_count_plural": "{{count}} POIs", + "route_error": "Karten-App konnte nicht geöffnet werden", + "set_status_destination": "Als Statusziel verwenden", + "sort_label": "Sortieren", + "sort_name": "Name", + "sort_type": "Typ", + "title": "POI", + "type_label": "Typ", + "view_details": "Details anzeigen", + "view_on_map": "Auf Karte anzeigen", + "view_on_map_hint": "Lassen Sie die Karte geöffnet, um Ihren Standort und den ausgewählten POI zusammen zu sehen." + }, "protocols": { "details": { "close": "Schließen", diff --git a/src/translations/en.json b/src/translations/en.json index 125d8fc..3cf038d 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -180,6 +180,7 @@ "contact_info": "Contact Info", "contact_name": "Contact Name", "contact_phone": "Phone", + "destination": "Destination", "edit_call": "Edit Call", "external_id": "External ID", "failed_to_open_maps": "Failed to open maps application", @@ -219,6 +220,7 @@ "notes": "Notes", "priority": "Priority", "reference_id": "Reference ID", + "route_to_destination": "Route to Destination", "status": "Status", "tabs": { "contact": "Contact", @@ -269,6 +271,9 @@ "description": "Description", "description_placeholder": "Enter the description of the call", "deselect": "Deselect", + "destination": "Destination", + "destination_load_error": "Failed to load destination POIs", + "destination_placeholder": "Select destination", "directions": "Directions", "dispatch_to": "Dispatch To", "dispatch_to_everyone": "Dispatch to all available personnel", @@ -399,6 +404,7 @@ "no_location": "No location available", "no_results_found": "No results found", "no_unit_selected": "No Unit Selected", + "none": "None", "of": "of", "ok": "OK", "optional": "Optional", @@ -657,10 +663,16 @@ "call_set_as_current": "Call set as current call", "failed_to_open_maps": "Failed to open maps application", "failed_to_set_current_call": "Failed to set call as current call", + "loading_markers": "Loading map markers...", "no_location_for_routing": "No location data available for routing", + "openSideMenu": "Open side menu", "pin_color": "Pin Color", "recenter_map": "Recenter Map", "set_as_current_call": "Set as Current Call", + "tabs": { + "map": "Map", + "pois": "POIs" + }, "view_call_details": "View Call Details" }, "maps": { @@ -793,15 +805,21 @@ "calls_tab": "Calls", "confirm_status": "Confirm Status: {{status}}", "custom_responding_to": "Custom Responding To", + "destination_required": "A destination is required for this status", "general_status": "General status update", + "gps_required": "GPS location is required for this status", + "loading_pois": "Loading POIs...", "loading_stations": "Loading stations...", + "missing_required_info": "Missing required information", "no_destination": "No Destination", "no_stations_available": "No stations available", "note": "Note", "note_placeholder": "Enter a note (optional)...", + "pois_tab": "POI", "responding_to": "Responding To", "responding_to_placeholder": "Enter what you're responding to...", "review_and_confirm": "Review and Confirm", + "saved_offline": "Status saved offline and will be submitted when connection is restored", "select_call_to_respond_to": "Select Call to Respond To", "select_destination": "Select Destination", "select_responding_to": "Set Status: {{status}}", @@ -812,6 +830,35 @@ "status": "Status" } }, + "poi": { + "address_label": "Address", + "coordinates_label": "Coordinates", + "destination_enabled": "Destination enabled", + "empty_description": "No POIs match the current filter.", + "empty_title": "No POIs found", + "filter_all_types": "All types", + "filter_label": "Filter", + "invalid_description": "A POI id is required to view POI details.", + "invalid_title": "Invalid POI", + "list_description": "Browse department POIs, filter by type, and jump back to the map.", + "load_error_title": "Unable to load POI", + "loading": "Loading POIs...", + "loading_detail": "Loading POI details...", + "not_found": "POI not found", + "note_label": "Note", + "results_count": "{{count}} POI", + "results_count_plural": "{{count}} POIs", + "route_error": "Failed to open maps application", + "set_status_destination": "Set status destination", + "sort_label": "Sort", + "sort_name": "Name", + "sort_type": "Type", + "title": "POI", + "type_label": "Type", + "view_details": "View Details", + "view_on_map": "View on Map", + "view_on_map_hint": "Keep the map open to see your location and the selected POI together." + }, "protocols": { "details": { "close": "Close", diff --git a/src/translations/es.json b/src/translations/es.json index 43148f2..f056a30 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -180,6 +180,7 @@ "contact_info": "Información de contacto", "contact_name": "Nombre del contacto", "contact_phone": "Teléfono", + "destination": "Destino", "edit_call": "Editar llamada", "external_id": "ID externo", "failed_to_open_maps": "Error al abrir la aplicación de mapas", @@ -219,6 +220,7 @@ "notes": "Notas", "priority": "Prioridad", "reference_id": "ID de referencia", + "route_to_destination": "Ir al destino", "status": "Estado", "tabs": { "contact": "Contacto", @@ -269,6 +271,9 @@ "description": "Descripción", "description_placeholder": "Introduce la descripción de la llamada", "deselect": "Deseleccionar", + "destination": "Destino", + "destination_load_error": "No se pudieron cargar los POI de destino", + "destination_placeholder": "Seleccionar destino", "directions": "Direcciones", "dispatch_to": "Despachar A", "dispatch_to_everyone": "Despachar a todo el personal disponible", @@ -399,6 +404,7 @@ "no_location": "No hay ubicación disponible", "no_results_found": "No se encontraron resultados", "no_unit_selected": "Ninguna unidad seleccionada", + "none": "Ninguno", "of": "de", "ok": "Aceptar", "optional": "Opcional", @@ -657,10 +663,16 @@ "call_set_as_current": "Llamada establecida como llamada actual", "failed_to_open_maps": "Error al abrir la aplicación de mapas", "failed_to_set_current_call": "Error al establecer la llamada como llamada actual", + "loading_markers": "Cargando marcadores del mapa...", "no_location_for_routing": "No hay datos de ubicación disponibles para el enrutamiento", + "openSideMenu": "Abrir menú lateral", "pin_color": "Color del pin", "recenter_map": "Recentrar mapa", "set_as_current_call": "Establecer como llamada actual", + "tabs": { + "map": "Mapa", + "pois": "POI" + }, "view_call_details": "Ver detalles de la llamada" }, "maps": { @@ -793,15 +805,21 @@ "calls_tab": "Llamadas", "confirm_status": "Confirmar Estado: {{status}}", "custom_responding_to": "Respondiendo a Personalizado", + "destination_required": "Se requiere un destino para este estado", "general_status": "Actualización de estado general", + "gps_required": "Se requiere ubicación GPS para este estado", + "loading_pois": "Cargando POI...", "loading_stations": "Cargando estaciones...", + "missing_required_info": "Falta información requerida", "no_destination": "Sin Destino", "no_stations_available": "No hay estaciones disponibles", "note": "Nota", "note_placeholder": "Introduce una nota (opcional)...", + "pois_tab": "POI", "responding_to": "Respondiendo A", "responding_to_placeholder": "Introduce a qué estás respondiendo...", "review_and_confirm": "Revisar y Confirmar", + "saved_offline": "El estado se guardó sin conexión y se enviará cuando vuelva la conexión", "select_call_to_respond_to": "Seleccionar Llamada a Responder", "select_destination": "Seleccionar Destino", "select_responding_to": "Establecer Estado: {{status}}", @@ -812,6 +830,35 @@ "status": "Estado" } }, + "poi": { + "address_label": "Dirección", + "coordinates_label": "Coordenadas", + "destination_enabled": "Destino permitido", + "empty_description": "Ningún POI coincide con el filtro actual.", + "empty_title": "No se encontraron POI", + "filter_all_types": "Todos los tipos", + "filter_label": "Filtro", + "invalid_description": "Se requiere un identificador de POI para ver los detalles.", + "invalid_title": "POI no válido", + "list_description": "Explora los POI del departamento, filtra por tipo y vuelve al mapa.", + "load_error_title": "No se pudo cargar el POI", + "loading": "Cargando POI...", + "loading_detail": "Cargando detalles del POI...", + "not_found": "POI no encontrado", + "note_label": "Nota", + "results_count": "{{count}} POI", + "results_count_plural": "{{count}} POIs", + "route_error": "No se pudo abrir la aplicación de mapas", + "set_status_destination": "Usar como destino del estado", + "sort_label": "Ordenar", + "sort_name": "Nombre", + "sort_type": "Tipo", + "title": "POI", + "type_label": "Tipo", + "view_details": "Ver detalles", + "view_on_map": "Ver en el mapa", + "view_on_map_hint": "Mantén el mapa abierto para ver tu ubicación y el POI seleccionado juntos." + }, "protocols": { "details": { "close": "Cerrar", diff --git a/src/translations/fr.json b/src/translations/fr.json index 5e31316..3090cd9 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -180,6 +180,7 @@ "contact_info": "Coordonnées", "contact_name": "Nom du contact", "contact_phone": "Téléphone", + "destination": "Destination", "edit_call": "Modifier l'appel", "external_id": "ID externe", "failed_to_open_maps": "Échec de l'ouverture de l'application de cartographie", @@ -219,6 +220,7 @@ "notes": "Notes", "priority": "Priorité", "reference_id": "ID de référence", + "route_to_destination": "Itinéraire vers la destination", "status": "Statut", "tabs": { "contact": "Contact", @@ -269,6 +271,9 @@ "description": "Description", "description_placeholder": "Saisir la description de l'appel", "deselect": "Désélectionner", + "destination": "Destination", + "destination_load_error": "Impossible de charger les POI de destination", + "destination_placeholder": "Sélectionner une destination", "directions": "Itinéraire", "dispatch_to": "Dépêcher vers", "dispatch_to_everyone": "Dépêcher tout le personnel disponible", @@ -399,6 +404,7 @@ "no_location": "Aucune localisation disponible", "no_results_found": "Aucun résultat trouvé", "no_unit_selected": "Aucune unité sélectionnée", + "none": "Aucun", "of": "de", "ok": "OK", "optional": "Facultatif", @@ -657,10 +663,16 @@ "call_set_as_current": "Appel défini comme appel actuel", "failed_to_open_maps": "Échec de l'ouverture de l'application de cartographie", "failed_to_set_current_call": "Échec de la définition de l'appel comme appel actuel", + "loading_markers": "Chargement des marqueurs de carte...", "no_location_for_routing": "Aucune donnée de localisation disponible pour l'itinéraire", + "openSideMenu": "Ouvrir le menu latéral", "pin_color": "Couleur du repère", "recenter_map": "Recentrer la carte", "set_as_current_call": "Définir comme appel actuel", + "tabs": { + "map": "Carte", + "pois": "POI" + }, "view_call_details": "Voir les détails de l'appel" }, "maps": { @@ -793,15 +805,21 @@ "calls_tab": "Appels", "confirm_status": "Confirmer le statut : {{status}}", "custom_responding_to": "Répondre à (personnalisé)", + "destination_required": "Une destination est requise pour ce statut", "general_status": "Mise à jour générale du statut", + "gps_required": "La position GPS est requise pour ce statut", + "loading_pois": "Chargement des POI...", "loading_stations": "Chargement des postes...", + "missing_required_info": "Informations requises manquantes", "no_destination": "Aucune destination", "no_stations_available": "Aucun poste disponible", "note": "Note", "note_placeholder": "Saisir une note (facultatif)...", + "pois_tab": "POI", "responding_to": "Répond à", "responding_to_placeholder": "Saisir ce à quoi vous répondez...", "review_and_confirm": "Vérifier et confirmer", + "saved_offline": "Le statut a été enregistré hors ligne et sera envoyé lorsque la connexion sera rétablie", "select_call_to_respond_to": "Sélectionner l'appel auquel répondre", "select_destination": "Sélectionner la destination", "select_responding_to": "Définir le statut : {{status}}", @@ -812,6 +830,35 @@ "status": "Statut" } }, + "poi": { + "address_label": "Adresse", + "coordinates_label": "Coordonnées", + "destination_enabled": "Destination autorisée", + "empty_description": "Aucun POI ne correspond au filtre actuel.", + "empty_title": "Aucun POI trouvé", + "filter_all_types": "Tous les types", + "filter_label": "Filtre", + "invalid_description": "Un identifiant de POI est requis pour afficher les détails.", + "invalid_title": "POI invalide", + "list_description": "Parcourez les POI du service, filtrez par type et revenez à la carte.", + "load_error_title": "Impossible de charger le POI", + "loading": "Chargement des POI...", + "loading_detail": "Chargement des détails du POI...", + "not_found": "POI introuvable", + "note_label": "Remarque", + "results_count": "{{count}} POI", + "results_count_plural": "{{count}} POIs", + "route_error": "Impossible d'ouvrir l'application de cartographie", + "set_status_destination": "Utiliser comme destination du statut", + "sort_label": "Trier", + "sort_name": "Nom", + "sort_type": "Type", + "title": "POI", + "type_label": "Type", + "view_details": "Voir les détails", + "view_on_map": "Voir sur la carte", + "view_on_map_hint": "Gardez la carte ouverte pour voir votre position et le POI sélectionné ensemble." + }, "protocols": { "details": { "close": "Fermer", diff --git a/src/translations/it.json b/src/translations/it.json index f966e59..59e8b5f 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -180,6 +180,7 @@ "contact_info": "Informazioni di contatto", "contact_name": "Nome del contatto", "contact_phone": "Telefono", + "destination": "Destinazione", "edit_call": "Modifica chiamata", "external_id": "ID esterno", "failed_to_open_maps": "Impossibile aprire l'applicazione di mappe", @@ -219,6 +220,7 @@ "notes": "Note", "priority": "Priorità", "reference_id": "ID di riferimento", + "route_to_destination": "Percorso verso la destinazione", "status": "Stato", "tabs": { "contact": "Contatto", @@ -269,6 +271,9 @@ "description": "Descrizione", "description_placeholder": "Inserire la descrizione della chiamata", "deselect": "Deseleziona", + "destination": "Destinazione", + "destination_load_error": "Impossibile caricare i POI di destinazione", + "destination_placeholder": "Seleziona destinazione", "directions": "Indicazioni", "dispatch_to": "Invia a", "dispatch_to_everyone": "Invia a tutto il personale disponibile", @@ -399,6 +404,7 @@ "no_location": "Nessuna posizione disponibile", "no_results_found": "Nessun risultato trovato", "no_unit_selected": "Nessuna unità selezionata", + "none": "Nessuno", "of": "di", "ok": "OK", "optional": "Facoltativo", @@ -657,10 +663,16 @@ "call_set_as_current": "Chiamata impostata come chiamata corrente", "failed_to_open_maps": "Impossibile aprire l'applicazione di mappe", "failed_to_set_current_call": "Impossibile impostare la chiamata come chiamata corrente", + "loading_markers": "Caricamento indicatori mappa...", "no_location_for_routing": "Nessun dato di posizione disponibile per il percorso", + "openSideMenu": "Apri menu laterale", "pin_color": "Colore del segnaposto", "recenter_map": "Ricentra mappa", "set_as_current_call": "Imposta come chiamata corrente", + "tabs": { + "map": "Mappa", + "pois": "POI" + }, "view_call_details": "Visualizza dettagli chiamata" }, "maps": { @@ -793,15 +805,21 @@ "calls_tab": "Chiamate", "confirm_status": "Conferma stato: {{status}}", "custom_responding_to": "Risponde a (personalizzato)", + "destination_required": "Per questo stato è richiesta una destinazione", "general_status": "Aggiornamento stato generale", + "gps_required": "Per questo stato è richiesta la posizione GPS", + "loading_pois": "Caricamento POI...", "loading_stations": "Caricamento stazioni...", + "missing_required_info": "Informazioni richieste mancanti", "no_destination": "Nessuna destinazione", "no_stations_available": "Nessuna stazione disponibile", "note": "Nota", "note_placeholder": "Inserire una nota (facoltativo)...", + "pois_tab": "POI", "responding_to": "Risponde a", "responding_to_placeholder": "Inserire a cosa si sta rispondendo...", "review_and_confirm": "Rivedi e conferma", + "saved_offline": "Lo stato è stato salvato offline e verrà inviato quando la connessione sarà ripristinata", "select_call_to_respond_to": "Seleziona la chiamata a cui rispondere", "select_destination": "Seleziona destinazione", "select_responding_to": "Imposta stato: {{status}}", @@ -812,6 +830,35 @@ "status": "Stato" } }, + "poi": { + "address_label": "Indirizzo", + "coordinates_label": "Coordinate", + "destination_enabled": "Destinazione consentita", + "empty_description": "Nessun POI corrisponde al filtro corrente.", + "empty_title": "Nessun POI trovato", + "filter_all_types": "Tutti i tipi", + "filter_label": "Filtro", + "invalid_description": "Per visualizzare i dettagli del POI è necessario un ID POI.", + "invalid_title": "POI non valido", + "list_description": "Sfoglia i POI del dipartimento, filtra per tipo e torna alla mappa.", + "load_error_title": "Impossibile caricare il POI", + "loading": "Caricamento POI...", + "loading_detail": "Caricamento dettagli POI...", + "not_found": "POI non trovato", + "note_label": "Nota", + "results_count": "{{count}} POI", + "results_count_plural": "{{count}} POIs", + "route_error": "Impossibile aprire l'app di mappe", + "set_status_destination": "Usa come destinazione dello stato", + "sort_label": "Ordina", + "sort_name": "Nome", + "sort_type": "Tipo", + "title": "POI", + "type_label": "Tipo", + "view_details": "Visualizza dettagli", + "view_on_map": "Mostra sulla mappa", + "view_on_map_hint": "Tieni aperta la mappa per vedere insieme la tua posizione e il POI selezionato." + }, "protocols": { "details": { "close": "Chiudi", diff --git a/src/translations/pl.json b/src/translations/pl.json index 1c48508..42f0187 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -180,6 +180,7 @@ "contact_info": "Dane kontaktowe", "contact_name": "Imię i nazwisko osoby kontaktowej", "contact_phone": "Telefon", + "destination": "Cel", "edit_call": "Edytuj zgłoszenie", "external_id": "Identyfikator zewnętrzny", "failed_to_open_maps": "Nie udało się otworzyć aplikacji map", @@ -219,6 +220,7 @@ "notes": "Notatki", "priority": "Priorytet", "reference_id": "Identyfikator referencyjny", + "route_to_destination": "Trasa do celu", "status": "Status", "tabs": { "contact": "Kontakt", @@ -269,6 +271,9 @@ "description": "Opis", "description_placeholder": "Wprowadź opis zgłoszenia", "deselect": "Odznacz", + "destination": "Cel", + "destination_load_error": "Nie udało się załadować docelowych POI", + "destination_placeholder": "Wybierz cel", "directions": "Wskazówki dojazdu", "dispatch_to": "Zadysponuj do", "dispatch_to_everyone": "Zadysponuj do całego dostępnego personelu", @@ -399,6 +404,7 @@ "no_location": "Brak dostępnej lokalizacji", "no_results_found": "Nie znaleziono wyników", "no_unit_selected": "Nie wybrano jednostki", + "none": "Brak", "of": "z", "ok": "OK", "optional": "Opcjonalne", @@ -657,10 +663,16 @@ "call_set_as_current": "Zgłoszenie ustawione jako bieżące", "failed_to_open_maps": "Nie udało się otworzyć aplikacji map", "failed_to_set_current_call": "Nie udało się ustawić zgłoszenia jako bieżącego", + "loading_markers": "Ładowanie znaczników mapy...", "no_location_for_routing": "Brak danych o lokalizacji do wyznaczenia trasy", + "openSideMenu": "Otwórz menu boczne", "pin_color": "Kolor pinezki", "recenter_map": "Wyśrodkuj mapę", "set_as_current_call": "Ustaw jako bieżące zgłoszenie", + "tabs": { + "map": "Mapa", + "pois": "POI" + }, "view_call_details": "Wyświetl szczegóły zgłoszenia" }, "maps": { @@ -793,15 +805,21 @@ "calls_tab": "Zgłoszenia", "confirm_status": "Potwierdź status: {{status}}", "custom_responding_to": "Niestandardowe reagowanie na", + "destination_required": "Ten status wymaga celu", "general_status": "Ogólna aktualizacja statusu", + "gps_required": "Ten status wymaga lokalizacji GPS", + "loading_pois": "Ładowanie POI...", "loading_stations": "Ładowanie stacji...", + "missing_required_info": "Brakuje wymaganych informacji", "no_destination": "Brak miejsca docelowego", "no_stations_available": "Brak dostępnych stacji", "note": "Notatka", "note_placeholder": "Wprowadź notatkę (opcjonalnie)...", + "pois_tab": "POI", "responding_to": "Reagowanie na", "responding_to_placeholder": "Wprowadź, na co Państwo reagują...", "review_and_confirm": "Przejrzyj i potwierdź", + "saved_offline": "Status zapisano offline i zostanie wysłany po przywróceniu połączenia", "select_call_to_respond_to": "Wybierz zgłoszenie, na które chcą Państwo reagować", "select_destination": "Wybierz miejsce docelowe", "select_responding_to": "Ustaw status: {{status}}", @@ -812,6 +830,35 @@ "status": "Status" } }, + "poi": { + "address_label": "Adres", + "coordinates_label": "Współrzędne", + "destination_enabled": "Może być celem", + "empty_description": "Żaden POI nie pasuje do bieżącego filtra.", + "empty_title": "Nie znaleziono POI", + "filter_all_types": "Wszystkie typy", + "filter_label": "Filtr", + "invalid_description": "Aby wyświetlić szczegóły POI, wymagany jest identyfikator POI.", + "invalid_title": "Nieprawidłowy POI", + "list_description": "Przeglądaj POI działu, filtruj według typu i wróć do mapy.", + "load_error_title": "Nie udało się załadować POI", + "loading": "Ładowanie POI...", + "loading_detail": "Ładowanie szczegółów POI...", + "not_found": "Nie znaleziono POI", + "note_label": "Notatka", + "results_count": "{{count}} POI", + "results_count_plural": "{{count}} POIs", + "route_error": "Nie udało się otworzyć aplikacji map", + "set_status_destination": "Użyj jako celu statusu", + "sort_label": "Sortuj", + "sort_name": "Nazwa", + "sort_type": "Typ", + "title": "POI", + "type_label": "Typ", + "view_details": "Zobacz szczegóły", + "view_on_map": "Pokaż na mapie", + "view_on_map_hint": "Pozostaw mapę otwartą, aby jednocześnie widzieć swoją lokalizację i wybrany POI." + }, "protocols": { "details": { "close": "Zamknij", diff --git a/src/translations/sv.json b/src/translations/sv.json index fe9d858..6097bfc 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -180,6 +180,7 @@ "contact_info": "Kontaktuppgifter", "contact_name": "Kontaktnamn", "contact_phone": "Telefon", + "destination": "Destination", "edit_call": "Redigera larm", "external_id": "Externt ID", "failed_to_open_maps": "Det gick inte att öppna kartprogrammet", @@ -219,6 +220,7 @@ "notes": "Anteckningar", "priority": "Prioritet", "reference_id": "Referens-ID", + "route_to_destination": "Vägbeskrivning till destination", "status": "Status", "tabs": { "contact": "Kontakt", @@ -269,6 +271,9 @@ "description": "Beskrivning", "description_placeholder": "Ange larmets beskrivning", "deselect": "Avmarkera", + "destination": "Destination", + "destination_load_error": "Det gick inte att läsa in destinations-POI:er", + "destination_placeholder": "Välj destination", "directions": "Vägbeskrivning", "dispatch_to": "Dirigera till", "dispatch_to_everyone": "Dirigera till all tillgänglig personal", @@ -399,6 +404,7 @@ "no_location": "Ingen plats tillgänglig", "no_results_found": "Inga resultat hittades", "no_unit_selected": "Ingen enhet vald", + "none": "Ingen", "of": "av", "ok": "OK", "optional": "Valfritt", @@ -657,10 +663,16 @@ "call_set_as_current": "Larmet angavs som aktuellt larm", "failed_to_open_maps": "Det gick inte att öppna kartprogrammet", "failed_to_set_current_call": "Det gick inte att ange larmet som aktuellt larm", + "loading_markers": "Laddar kartmarkörer...", "no_location_for_routing": "Ingen platsdata tillgänglig för navigering", + "openSideMenu": "Öppna sidomenyn", "pin_color": "Nålfärg", "recenter_map": "Centrera om kartan", "set_as_current_call": "Ange som aktuellt larm", + "tabs": { + "map": "Karta", + "pois": "POI" + }, "view_call_details": "Visa larmdetaljer" }, "maps": { @@ -793,15 +805,21 @@ "calls_tab": "Larm", "confirm_status": "Bekräfta status: {{status}}", "custom_responding_to": "Anpassad svar till", + "destination_required": "En destination krävs för denna status", "general_status": "Allmän statusuppdatering", + "gps_required": "GPS-position krävs för denna status", + "loading_pois": "Laddar POI:er...", "loading_stations": "Läser in stationer...", + "missing_required_info": "Nödvändig information saknas", "no_destination": "Ingen destination", "no_stations_available": "Inga stationer tillgängliga", "note": "Anteckning", "note_placeholder": "Ange en anteckning (valfritt)...", + "pois_tab": "POI", "responding_to": "Svarar till", "responding_to_placeholder": "Ange vad du svarar till...", "review_and_confirm": "Granska och bekräfta", + "saved_offline": "Statusen sparades offline och skickas när anslutningen är tillbaka", "select_call_to_respond_to": "Välj larm att svara till", "select_destination": "Välj destination", "select_responding_to": "Ange status: {{status}}", @@ -812,6 +830,35 @@ "status": "Status" } }, + "poi": { + "address_label": "Adress", + "coordinates_label": "Koordinater", + "destination_enabled": "Kan användas som destination", + "empty_description": "Inga POI:er matchar det aktuella filtret.", + "empty_title": "Inga POI:er hittades", + "filter_all_types": "Alla typer", + "filter_label": "Filter", + "invalid_description": "Ett POI-id krävs för att visa POI-detaljer.", + "invalid_title": "Ogiltig POI", + "list_description": "Bläddra bland avdelningens POI:er, filtrera efter typ och hoppa tillbaka till kartan.", + "load_error_title": "Det gick inte att läsa in POI", + "loading": "Laddar POI:er...", + "loading_detail": "Laddar POI-detaljer...", + "not_found": "POI hittades inte", + "note_label": "Anteckning", + "results_count": "{{count}} POI", + "results_count_plural": "{{count}} POIs", + "route_error": "Det gick inte att öppna kartappen", + "set_status_destination": "Använd som statusdestination", + "sort_label": "Sortera", + "sort_name": "Namn", + "sort_type": "Typ", + "title": "POI", + "type_label": "Typ", + "view_details": "Visa detaljer", + "view_on_map": "Visa på karta", + "view_on_map_hint": "Håll kartan öppen för att se din plats och den valda POI:n tillsammans." + }, "protocols": { "details": { "close": "Stäng", diff --git a/src/translations/uk.json b/src/translations/uk.json index f246ad1..516a173 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -180,6 +180,7 @@ "contact_info": "Контактна інформація", "contact_name": "Ім'я контактної особи", "contact_phone": "Телефон", + "destination": "Пункт призначення", "edit_call": "Редагувати виклик", "external_id": "Зовнішній ідентифікатор", "failed_to_open_maps": "Не вдалося відкрити програму карт", @@ -219,6 +220,7 @@ "notes": "Примітки", "priority": "Пріоритет", "reference_id": "Ідентифікатор посилання", + "route_to_destination": "Прокласти маршрут до пункту призначення", "status": "Статус", "tabs": { "contact": "Контакт", @@ -269,6 +271,9 @@ "description": "Опис", "description_placeholder": "Введіть опис виклику", "deselect": "Зняти вибір", + "destination": "Пункт призначення", + "destination_load_error": "Не вдалося завантажити POI для пункту призначення", + "destination_placeholder": "Виберіть пункт призначення", "directions": "Маршрут", "dispatch_to": "Задіяти до", "dispatch_to_everyone": "Задіяти весь доступний персонал", @@ -399,6 +404,7 @@ "no_location": "Місцезнаходження недоступне", "no_results_found": "Результати не знайдено", "no_unit_selected": "Підрозділ не вибрано", + "none": "Немає", "of": "з", "ok": "OK", "optional": "Необов'язково", @@ -657,10 +663,16 @@ "call_set_as_current": "Виклик встановлено як поточний", "failed_to_open_maps": "Не вдалося відкрити програму карт", "failed_to_set_current_call": "Не вдалося встановити виклик як поточний", + "loading_markers": "Завантаження маркерів карти...", "no_location_for_routing": "Дані про місцезнаходження для прокладання маршруту відсутні", + "openSideMenu": "Відкрити бічне меню", "pin_color": "Колір маркера", "recenter_map": "Відцентрувати карту", "set_as_current_call": "Встановити як поточний виклик", + "tabs": { + "map": "Карта", + "pois": "POI" + }, "view_call_details": "Переглянути деталі виклику" }, "maps": { @@ -793,15 +805,21 @@ "calls_tab": "Виклики", "confirm_status": "Підтвердити статус: {{status}}", "custom_responding_to": "Нестандартне реагування на", + "destination_required": "Для цього статусу потрібен пункт призначення", "general_status": "Загальне оновлення статусу", + "gps_required": "Для цього статусу потрібне GPS-місцезнаходження", + "loading_pois": "Завантаження POI...", "loading_stations": "Завантаження станцій...", + "missing_required_info": "Бракує обов’язкової інформації", "no_destination": "Пункт призначення відсутній", "no_stations_available": "Станції недоступні", "note": "Примітка", "note_placeholder": "Введіть примітку (необов'язково)...", + "pois_tab": "POI", "responding_to": "Реагування на", "responding_to_placeholder": "Введіть, на що ви реагуєте...", "review_and_confirm": "Перегляньте та підтвердіть", + "saved_offline": "Статус збережено офлайн і буде надіслано після відновлення з’єднання", "select_call_to_respond_to": "Вибрати виклик для реагування", "select_destination": "Вибрати пункт призначення", "select_responding_to": "Встановити статус: {{status}}", @@ -812,6 +830,35 @@ "status": "Статус" } }, + "poi": { + "address_label": "Адреса", + "coordinates_label": "Координати", + "destination_enabled": "Доступно як пункт призначення", + "empty_description": "Жоден POI не відповідає поточному фільтру.", + "empty_title": "POI не знайдено", + "filter_all_types": "Усі типи", + "filter_label": "Фільтр", + "invalid_description": "Щоб переглянути деталі POI, потрібен ідентифікатор POI.", + "invalid_title": "Недійсний POI", + "list_description": "Переглядайте POI підрозділу, фільтруйте за типом і повертайтеся до карти.", + "load_error_title": "Не вдалося завантажити POI", + "loading": "Завантаження POI...", + "loading_detail": "Завантаження деталей POI...", + "not_found": "POI не знайдено", + "note_label": "Примітка", + "results_count": "{{count}} POI", + "results_count_plural": "{{count}} POIs", + "route_error": "Не вдалося відкрити програму карт", + "set_status_destination": "Використати як пункт призначення статусу", + "sort_label": "Сортувати", + "sort_name": "Назва", + "sort_type": "Тип", + "title": "POI", + "type_label": "Тип", + "view_details": "Переглянути деталі", + "view_on_map": "Показати на карті", + "view_on_map_hint": "Залишайте карту відкритою, щоб одночасно бачити своє місцезнаходження і вибраний POI." + }, "protocols": { "details": { "close": "Закрити",