${pin.Title}
- ${pin.InfoWindowContent ? `
${pin.InfoWindowContent}
` : ''}
+ ${getMapPinSummary(pin) ? `
${getMapPinSummary(pin)}
` : ''}
${pin.Latitude.toFixed(6)}, ${pin.Longitude.toFixed(6)}
@@ -241,7 +239,7 @@ export default function MapWeb() {
markersRef.current.push(marker);
});
- }, [mapPins, isMapReady, colorScheme]);
+ }, [visibleMapPins, isMapReady, colorScheme]);
// Update layers when visibility changes
useEffect(() => {
@@ -345,10 +343,10 @@ export default function MapWeb() {
mapPinsCount: mapPins.length,
theme: colorScheme || 'light',
platform: 'web',
- layersCount: layers.length,
- visibleLayersCount: visibleLayers.size,
+ layersCount: combinedLayers.length,
+ visibleLayersCount: visibleLayers.size + visiblePoiLayerIds.size,
});
- }, [trackEvent, mapPins.length, colorScheme, layers.length, visibleLayers.size]);
+ }, [trackEvent, mapPins.length, colorScheme, combinedLayers.length, visibleLayers.size, visiblePoiLayerIds.size]);
// Render layers panel modal
const renderLayersPanel = () => {
@@ -366,20 +364,20 @@ export default function MapWeb() {
-
+
{t('map.show_all')}
-
+
{t('map.hide_all')}
- {layers.length === 0 ? (
+ {combinedLayers.length === 0 ? (
{isLayersLoading ? t('common.loading') : t('map.no_layers')}
) : (
- layers.map((layer) => (
- toggleLayer(layer.Id)}>
+ combinedLayers.map((layer) => (
+ (layer.kind === 'custom' ? toggleLayer(layer.Id) : togglePoiLayer(layer.Id))}>
@@ -387,10 +385,10 @@ export default function MapWeb() {
toggleLayer(layer.Id)}
+ value={layer.kind === 'custom' ? visibleLayers.has(layer.Id) : visiblePoiLayerIds.has(layer.Id)}
+ onValueChange={() => (layer.kind === 'custom' ? toggleLayer(layer.Id) : togglePoiLayer(layer.Id))}
trackColor={{ false: isDark ? '#4b5563' : '#d1d5db', true: '#3b82f6' }}
- thumbColor={visibleLayers.has(layer.Id) ? '#ffffff' : '#f4f3f4'}
+ thumbColor={(layer.kind === 'custom' ? visibleLayers.has(layer.Id) : visiblePoiLayerIds.has(layer.Id)) ? '#ffffff' : '#f4f3f4'}
/>
))
diff --git a/src/app/(app)/pois.tsx b/src/app/(app)/pois.tsx
new file mode 100644
index 0000000..2a42957
--- /dev/null
+++ b/src/app/(app)/pois.tsx
@@ -0,0 +1,168 @@
+import { useFocusEffect } from '@react-navigation/native';
+import { type Href, router } from 'expo-router';
+import { ChevronDownIcon, MapPinned, Search, X } from 'lucide-react-native';
+import React, { useCallback, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { RefreshControl, View } from 'react-native';
+
+import { Loading } from '@/components/common/loading';
+import ZeroState from '@/components/common/zero-state';
+import { PoiCard } from '@/components/pois/poi-card';
+import { FocusAwareStatusBar } from '@/components/ui';
+import { Box } from '@/components/ui/box';
+import { FlatList } from '@/components/ui/flat-list';
+import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input';
+import { Select, SelectBackdrop, SelectContent, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select';
+import { getPoiPrimaryDisplayText, getPoiSearchValue } from '@/lib/poi-display';
+import { type PoiResultData } from '@/models/v4/mapping/poiResultData';
+import { usePoisStore } from '@/stores/pois/store';
+
+type PoiSortOption = 'name-asc' | 'name-desc' | 'type-asc' | 'address-asc';
+
+export default function Pois() {
+ const { t } = useTranslation();
+ const { pois, poiTypes, isLoading, error, fetchPoiTypes, fetchPois } = usePoisStore();
+ const [searchQuery, setSearchQuery] = useState('');
+ const [selectedPoiTypeId, setSelectedPoiTypeId] = useState('all');
+ const [sortOption, setSortOption] = useState('name-asc');
+ const [refreshing, setRefreshing] = useState(false);
+
+ useFocusEffect(
+ useCallback(() => {
+ fetchPoiTypes();
+ fetchPois();
+ }, [fetchPoiTypes, fetchPois])
+ );
+
+ const handleRefresh = useCallback(async () => {
+ setRefreshing(true);
+
+ try {
+ await Promise.all([fetchPoiTypes(true), fetchPois(true)]);
+ } finally {
+ setRefreshing(false);
+ }
+ }, [fetchPoiTypes, fetchPois]);
+
+ const filteredPois = useMemo(() => {
+ const normalizedQuery = searchQuery.trim().toLowerCase();
+
+ return [...pois]
+ .filter((poi) => (selectedPoiTypeId === 'all' ? true : poi.PoiTypeId.toString() === selectedPoiTypeId))
+ .filter((poi) => (normalizedQuery ? getPoiSearchValue(poi).includes(normalizedQuery) : true))
+ .sort((left, right) => sortPois(left, right, sortOption));
+ }, [pois, searchQuery, selectedPoiTypeId, sortOption]);
+
+ const typeValue = poiTypes.find((poiType) => poiType.PoiTypeId.toString() === selectedPoiTypeId)?.Name ?? t('pois.all_types');
+ const sortValue = t(`pois.sort_options.${sortOption}`);
+
+ if (isLoading && pois.length === 0) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {searchQuery ? (
+ setSearchQuery('')}>
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+ {error && pois.length === 0 ? (
+
+ ) : (
+
+ testID="pois-list"
+ data={filteredPois}
+ keyExtractor={(item) => item.PoiId.toString()}
+ renderItem={({ item }) => router.push(`/poi/${poi.PoiId}` as Href)} />}
+ contentContainerStyle={{ paddingBottom: 24 }}
+ refreshControl={}
+ ListEmptyComponent={
+
+ }
+ />
+ )}
+
+
+ );
+}
+
+const sortPois = (left: PoiResultData, right: PoiResultData, sortOption: PoiSortOption) => {
+ switch (sortOption) {
+ case 'name-desc':
+ return getSortValue(right, 'name').localeCompare(getSortValue(left, 'name'));
+ case 'type-asc':
+ return getSortValue(left, 'type').localeCompare(getSortValue(right, 'type')) || getSortValue(left, 'name').localeCompare(getSortValue(right, 'name'));
+ case 'address-asc':
+ return getSortValue(left, 'address').localeCompare(getSortValue(right, 'address')) || getSortValue(left, 'name').localeCompare(getSortValue(right, 'name'));
+ case 'name-asc':
+ default:
+ return getSortValue(left, 'name').localeCompare(getSortValue(right, 'name'));
+ }
+};
+
+const getSortValue = (poi: PoiResultData, field: 'name' | 'type' | 'address') => {
+ switch (field) {
+ case 'type':
+ return (poi.PoiTypeName || '').toLowerCase();
+ case 'address':
+ return (poi.Address || getPoiPrimaryDisplayText(poi)).toLowerCase();
+ case 'name':
+ default:
+ return getPoiPrimaryDisplayText(poi).toLowerCase();
+ }
+};
diff --git a/src/app/(app)/scheduled-calls.tsx b/src/app/(app)/scheduled-calls.tsx
new file mode 100644
index 0000000..4a92efb
--- /dev/null
+++ b/src/app/(app)/scheduled-calls.tsx
@@ -0,0 +1,248 @@
+import { useFocusEffect } from '@react-navigation/native';
+import { type Href, router, Stack } from 'expo-router';
+import { CalendarClockIcon, Search, X } from 'lucide-react-native';
+import { useColorScheme } from 'nativewind';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Pressable, RefreshControl, ScrollView, StyleSheet, Text as RNText, View } from 'react-native';
+
+import { Loading } from '@/components/common/loading';
+import ZeroState from '@/components/common/zero-state';
+import { Box } from '@/components/ui/box';
+import { FlatList } from '@/components/ui/flat-list';
+import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
+import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input';
+import { useAnalytics } from '@/hooks/use-analytics';
+import { formatDateForDisplay, parseDateISOString } from '@/lib/utils';
+import { type CallResultData } from '@/models/v4/calls/callResultData';
+import { useScheduledCallsStore } from '@/stores/calls/scheduled-store';
+import { useCallsStore } from '@/stores/calls/store';
+
+export default function ScheduledCalls() {
+ const { scheduledCalls, isLoading, error, fetchScheduledCalls } = useScheduledCallsStore();
+ const { fetchCallPriorities, callPriorities } = useCallsStore();
+ const { t } = useTranslation();
+ const { trackEvent } = useAnalytics();
+ const { colorScheme } = useColorScheme();
+ const isDark = colorScheme === 'dark';
+ const [searchQuery, setSearchQuery] = useState('');
+ const [isRefreshing, setIsRefreshing] = useState(false);
+
+ const themedStyles = useMemo(() => getThemedStyles(isDark), [isDark]);
+
+ useFocusEffect(
+ useCallback(() => {
+ fetchCallPriorities();
+ fetchScheduledCalls();
+ }, [fetchCallPriorities, fetchScheduledCalls])
+ );
+
+ useEffect(() => {
+ trackEvent('scheduled_calls_view_rendered', {
+ callsCount: scheduledCalls.length,
+ });
+ }, [trackEvent, scheduledCalls.length]);
+
+ const handleRefresh = useCallback(() => {
+ setIsRefreshing(true);
+ fetchScheduledCalls().finally(() => setIsRefreshing(false));
+ }, [fetchScheduledCalls]);
+
+ const filteredCalls = scheduledCalls.filter(
+ (call) =>
+ call.CallId.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ (call.Nature?.toLowerCase() || '').includes(searchQuery.toLowerCase()) ||
+ (call.Name?.toLowerCase() || '').includes(searchQuery.toLowerCase()) ||
+ (call.Address?.toLowerCase() || '').includes(searchQuery.toLowerCase()) ||
+ (call.Number?.toLowerCase() || '').includes(searchQuery.toLowerCase())
+ );
+
+ const renderContent = () => {
+ if (isLoading) {
+ return ;
+ }
+
+ if (error) {
+ return ;
+ }
+
+ if (filteredCalls.length === 0) {
+ return ;
+ }
+
+ return (
+
+
+ {/* Table Header */}
+
+ {t('scheduled_calls.table_number')}
+ {t('scheduled_calls.table_name')}
+ {t('scheduled_calls.table_type')}
+ {t('scheduled_calls.table_priority')}
+ {t('scheduled_calls.table_address')}
+ {t('scheduled_calls.table_scheduled')}
+
+
+ {/* Table Rows */}
+
+ testID="scheduled-calls-list"
+ data={filteredCalls}
+ renderItem={({ item, index }: { item: CallResultData; index: number }) => {
+ const priority = callPriorities.find((p) => p.Id === item.Priority);
+ const scheduledDate = formatDateForDisplay(parseDateISOString(item.ScheduledOn || item.ScheduledOnUtc), 'MMM d, yyyy h:mm a');
+ const rowBg = { backgroundColor: index % 2 === 0 ? themedStyles.rowEvenBg : themedStyles.rowOddBg };
+
+ return (
+ router.push(`/call/${item.CallId}` as Href)} style={[styles.tableRow, { borderBottomColor: themedStyles.borderColor }, rowBg]}>
+
+
+ {item.Number || item.CallId}
+
+
+
+
+ {item.Name}
+
+
+
+
+ {item.Type || '-'}
+
+
+
+
+
+
+ {priority?.Name || '-'}
+
+
+
+
+
+ {item.Address || '-'}
+
+
+
+
+ {scheduledDate}
+
+
+
+ );
+ }}
+ keyExtractor={(item: CallResultData) => item.CallId}
+ refreshControl={}
+ contentContainerStyle={{ paddingBottom: 20 }}
+ />
+
+
+ );
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ {searchQuery ? (
+ setSearchQuery('')}>
+
+
+ ) : null}
+
+ {renderContent()}
+
+
+ );
+}
+
+const getThemedStyles = (isDark: boolean) => ({
+ headerBg: isDark ? '#1f2937' : '#f9fafb',
+ borderColor: isDark ? '#374151' : '#e5e7eb',
+ headerTextColor: isDark ? '#9ca3af' : '#6b7280',
+ rowEvenBg: isDark ? '#111827' : '#ffffff',
+ rowOddBg: isDark ? '#1f2937' : '#f9fafb',
+ textPrimary: isDark ? '#f3f4f6' : '#111827',
+ textSecondary: isDark ? '#d1d5db' : '#4b5563',
+ scheduledColor: isDark ? '#fbbf24' : '#d97706',
+});
+
+const styles = StyleSheet.create({
+ tableHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 10,
+ paddingHorizontal: 12,
+ borderBottomWidth: 2,
+ },
+ headerCell: {
+ fontSize: 12,
+ fontWeight: '700',
+ textTransform: 'uppercase',
+ letterSpacing: 0.5,
+ },
+ tableRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 12,
+ paddingHorizontal: 12,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+ cellContainer: {
+ justifyContent: 'center',
+ },
+ cellText: {
+ fontSize: 14,
+ fontWeight: '500',
+ },
+ cellTextBold: {
+ fontSize: 14,
+ fontWeight: '700',
+ },
+ cellTextSecondary: {
+ fontSize: 13,
+ },
+ cellTextScheduled: {
+ fontSize: 13,
+ fontWeight: '600',
+ },
+ priorityBadge: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ },
+ priorityDot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ },
+ cellNumber: {
+ width: 80,
+ },
+ cellName: {
+ width: 160,
+ },
+ cellType: {
+ width: 100,
+ },
+ cellPriority: {
+ width: 100,
+ },
+ cellAddress: {
+ width: 180,
+ },
+ cellScheduled: {
+ width: 160,
+ },
+});
diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx
index ce77726..66b54dc 100644
--- a/src/app/call/[id].tsx
+++ b/src/app/call/[id].tsx
@@ -1,4 +1,3 @@
-import { format } from 'date-fns';
import { type Href, Stack, useLocalSearchParams, useRouter } from 'expo-router';
import { ClockIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, PaperclipIcon, RouteIcon, ShieldCheckIcon, UserIcon, UsersIcon, VideoIcon } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
@@ -24,6 +23,7 @@ import { VStack } from '@/components/ui/vstack';
import { useAnalytics } from '@/hooks/use-analytics';
import { logger } from '@/lib/logging';
import { openMapsWithDirections } from '@/lib/navigation';
+import { formatDateForDisplay, parseDateISOString } from '@/lib/utils';
import { useCoreStore } from '@/stores/app/core-store';
import { useLocationStore } from '@/stores/app/location-store';
import { useCallDetailStore } from '@/stores/calls/detail-store';
@@ -284,8 +284,14 @@ export default function CallDetail() {
{t('call_detail.timestamp')}
- {format(new Date(call.LoggedOn), 'MMM d, h:mm a')}
+ {formatDateForDisplay(parseDateISOString(call.LoggedOn), 'MMM d, h:mm a')}
+ {call.ScheduledOn || call.ScheduledOnUtc ? (
+
+ {t('call_detail.scheduled_on')}
+ {formatDateForDisplay(parseDateISOString(call.ScheduledOn || call.ScheduledOnUtc), 'MMM d, yyyy h:mm a')}
+
+ ) : null}
{t('call_detail.type')}
{call.Type}
@@ -294,6 +300,24 @@ export default function CallDetail() {
{t('call_detail.address')}
{call.Address}
+ {call.DestinationName ? (
+
+ {t('call_detail.destination')}
+ {call.DestinationName}
+
+ ) : null}
+ {call.DestinationTypeName ? (
+
+ {t('call_detail.destination_type')}
+ {call.DestinationTypeName}
+
+ ) : null}
+ {call.DestinationAddress ? (
+
+ {t('call_detail.destination_address')}
+ {call.DestinationAddress}
+
+ ) : null}
{t('call_detail.note')}
diff --git a/src/app/call/[id].web.tsx b/src/app/call/[id].web.tsx
index c76acda..9ce1c44 100644
--- a/src/app/call/[id].web.tsx
+++ b/src/app/call/[id].web.tsx
@@ -1,4 +1,3 @@
-import { format } from 'date-fns';
import DOMPurify from 'dompurify';
import { type Href, Stack, useLocalSearchParams, useRouter } from 'expo-router';
import { ClockIcon, EditIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, MapPinIcon, PaperclipIcon, RouteIcon, ShieldCheckIcon, UserIcon, UsersIcon, VideoIcon, XCircleIcon } from 'lucide-react-native';
@@ -20,6 +19,7 @@ import { Text } from '@/components/ui/text';
import { useAnalytics } from '@/hooks/use-analytics';
import { logger } from '@/lib/logging';
import { openMapsWithDirections } from '@/lib/navigation';
+import { formatDateForDisplay, parseDateISOString } from '@/lib/utils';
import { useCoreStore } from '@/stores/app/core-store';
import { useLocationStore } from '@/stores/app/location-store';
import { useCallDetailStore } from '@/stores/calls/detail-store';
@@ -235,9 +235,15 @@ export default function CallDetailWeb() {
return (
-
+
+ {call.ScheduledOn || call.ScheduledOnUtc ? (
+
+ ) : null}
+
+
+
{t('call_detail.note')}
diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx
index 2c8c8e5..7469dcb 100644
--- a/src/app/call/[id]/edit.tsx
+++ b/src/app/call/[id]/edit.tsx
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
import { ScrollView, TouchableOpacity, View } from 'react-native';
import * as z from 'zod';
+import { getNewCallData } from '@/api/dispatch/dispatch';
import { saveUdfValues } from '@/api/userDefinedFields/userDefinedFields';
import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal';
import { UdfFieldsRenderer } from '@/components/calls/udf-fields-renderer';
@@ -26,6 +27,8 @@ import { Text } from '@/components/ui/text';
import { Textarea, TextareaInput } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/toast';
import { useAnalytics } from '@/hooks/use-analytics';
+import { getPoiDestinationOptionLabel } from '@/lib/poi-display';
+import { type PoiResultData } from '@/models/v4/mapping/poiResultData';
import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput';
import { useCoreStore } from '@/stores/app/core-store';
import { useCallDetailStore } from '@/stores/calls/detail-store';
@@ -37,6 +40,7 @@ const formSchema = z.object({
name: z.string().min(1, 'Name is required'),
nature: z.string().min(1, 'Nature is required'),
note: z.string().optional(),
+ destinationPoiId: z.string().optional(),
address: z.string().optional(),
coordinates: z.string().optional(),
what3words: z.string().optional(),
@@ -58,6 +62,8 @@ const formSchema = z.object({
type FormValues = z.infer;
+const NO_DESTINATION_VALUE = '__none__';
+
interface GeocodingResult {
place_id: string;
formatted_address: string;
@@ -94,6 +100,8 @@ export default function EditCall() {
const [isGeocodingCoordinates, setIsGeocodingCoordinates] = useState(false);
const [isGeocodingWhat3Words, setIsGeocodingWhat3Words] = useState(false);
const [addressResults, setAddressResults] = useState([]);
+ const [destinationPois, setDestinationPois] = useState([]);
+ const [isLoadingDestinationPois, setIsLoadingDestinationPois] = useState(false);
const [dispatchSelection, setDispatchSelection] = useState({
everyone: false,
users: [],
@@ -119,6 +127,7 @@ export default function EditCall() {
name: '',
nature: '',
note: '',
+ destinationPoiId: '',
address: '',
coordinates: '',
what3words: '',
@@ -147,6 +156,30 @@ export default function EditCall() {
}
}, [fetchCallPriorities, fetchCallTypes, fetchCallDetail, callId]);
+ useEffect(() => {
+ let isMounted = true;
+
+ setIsLoadingDestinationPois(true);
+ getNewCallData()
+ .then((result) => {
+ if (isMounted) {
+ setDestinationPois(result?.Data?.DestinationPois || []);
+ }
+ })
+ .catch((error) => {
+ console.error('Failed to load destination POIs:', error);
+ })
+ .finally(() => {
+ if (isMounted) {
+ setIsLoadingDestinationPois(false);
+ }
+ });
+
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
// Pre-populate form when call data is loaded
useEffect(() => {
if (call) {
@@ -183,6 +216,7 @@ export default function EditCall() {
name: call.Name || '',
nature: call.Nature || '',
note: call.Note || '',
+ destinationPoiId: call.DestinationPoiId ? call.DestinationPoiId.toString() : '',
address: call.Address || '',
coordinates: call.Geolocation || '',
what3words: '',
@@ -242,6 +276,7 @@ export default function EditCall() {
priority: priority?.Id || 0,
type: type?.Name || '',
note: data.note,
+ destinationPoiId: data.destinationPoiId ? Number(data.destinationPoiId) : null,
address: data.address,
latitude: data.latitude,
longitude: data.longitude,
@@ -641,6 +676,35 @@ export default function EditCall() {
)}
+
+
+
+ {t('calls.destination_poi')}
+
+ (
+
+ )}
+ />
+ {isLoadingDestinationPois ? {t('calls.loading_destination_pois')} : null}
+ {!isLoadingDestinationPois && destinationPois.length === 0 ? {t('calls.no_destination_pois_available')} : null}
+
diff --git a/src/app/call/[id]/edit.web.tsx b/src/app/call/[id]/edit.web.tsx
index c659546..5552b95 100644
--- a/src/app/call/[id]/edit.web.tsx
+++ b/src/app/call/[id]/edit.web.tsx
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
import { Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
import * as z from 'zod';
+import { getNewCallData } from '@/api/dispatch/dispatch';
import { saveUdfValues } from '@/api/userDefinedFields/userDefinedFields';
import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal';
import { UdfFieldsRenderer } from '@/components/calls/udf-fields-renderer';
@@ -20,6 +21,8 @@ import { Card } from '@/components/ui/card';
import { Text } from '@/components/ui/text';
import { useToast } from '@/components/ui/toast';
import { useAnalytics } from '@/hooks/use-analytics';
+import { getPoiDestinationOptionLabel } from '@/lib/poi-display';
+import { type PoiResultData } from '@/models/v4/mapping/poiResultData';
import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput';
import { useCoreStore } from '@/stores/app/core-store';
import { useCallDetailStore } from '@/stores/calls/detail-store';
@@ -31,6 +34,7 @@ const formSchema = z.object({
name: z.string().min(1, 'Name is required'),
nature: z.string().min(1, 'Nature is required'),
note: z.string().optional(),
+ destinationPoiId: z.string().optional(),
address: z.string().optional(),
coordinates: z.string().optional(),
what3words: z.string().optional(),
@@ -52,6 +56,8 @@ const formSchema = z.object({
type FormValues = z.infer;
+const NO_DESTINATION_VALUE = '__none__';
+
interface GeocodingResult {
place_id: string;
formatted_address: string;
@@ -142,9 +148,10 @@ interface WebSelectProps {
options: Array<{ id: string | number; name: string; color?: string }>;
error?: string;
required?: boolean;
+ useIdValue?: boolean;
}
-const WebSelect: React.FC = ({ label, placeholder, value, onChange, options, error, required = false }) => {
+const WebSelect: React.FC = ({ label, placeholder, value, onChange, options, error, required = false, useIdValue = false }) => {
const { colorScheme } = useColorScheme();
const isDark = colorScheme === 'dark';
@@ -161,7 +168,7 @@ const WebSelect: React.FC = ({ label, placeholder, value, onChan
>
{options.map((option) => (
-
))}
@@ -189,6 +196,8 @@ export default function EditCallWeb() {
const [showAddressSelection, setShowAddressSelection] = useState(false);
const [isGeocodingAddress, setIsGeocodingAddress] = useState(false);
const [addressResults, setAddressResults] = useState([]);
+ const [destinationPois, setDestinationPois] = useState([]);
+ const [isLoadingDestinationPois, setIsLoadingDestinationPois] = useState(false);
const [dispatchSelection, setDispatchSelection] = useState({
everyone: false,
users: [],
@@ -221,6 +230,7 @@ export default function EditCallWeb() {
name: '',
nature: '',
note: '',
+ destinationPoiId: '',
address: '',
coordinates: '',
what3words: '',
@@ -249,6 +259,30 @@ export default function EditCallWeb() {
if (callId) fetchCallDetail(callId);
}, [fetchCallPriorities, fetchCallTypes, fetchCallDetail, callId]);
+ useEffect(() => {
+ let isMounted = true;
+
+ setIsLoadingDestinationPois(true);
+ getNewCallData()
+ .then((result) => {
+ if (isMounted) {
+ setDestinationPois(result?.Data?.DestinationPois || []);
+ }
+ })
+ .catch((error) => {
+ console.error('Failed to load destination POIs:', error);
+ })
+ .finally(() => {
+ if (isMounted) {
+ setIsLoadingDestinationPois(false);
+ }
+ });
+
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
// Pre-populate form when call data is loaded
useEffect(() => {
if (call) {
@@ -285,6 +319,7 @@ export default function EditCallWeb() {
name: call.Name || '',
nature: call.Nature || '',
note: call.Note || '',
+ destinationPoiId: call.DestinationPoiId ? call.DestinationPoiId.toString() : '',
address: call.Address || '',
coordinates: call.Geolocation || '',
what3words: '',
@@ -372,8 +407,9 @@ export default function EditCallWeb() {
name: data.name,
nature: data.nature,
priority: priority?.Id || 0,
- type: type?.Name || '',
+ type: type?.Id || '',
note: data.note,
+ destinationPoiId: data.destinationPoiId ? Number(data.destinationPoiId) : null,
address: data.address,
latitude: data.latitude,
longitude: data.longitude,
@@ -761,6 +797,31 @@ export default function EditCallWeb() {
)}
+
+ (
+ onChange(selectedValue === NO_DESTINATION_VALUE ? '' : selectedValue)}
+ useIdValue
+ options={[
+ { id: NO_DESTINATION_VALUE, name: t('calls.no_destination') },
+ ...destinationPois.map((poi) => ({
+ id: poi.PoiId,
+ name: getPoiDestinationOptionLabel(poi),
+ })),
+ ]}
+ />
+ )}
+ />
+ {isLoadingDestinationPois ? {t('calls.loading_destination_pois')} : null}
+ {!isLoadingDestinationPois && destinationPois.length === 0 ? (
+ {t('calls.no_destination_pois_available')}
+ ) : null}
{/* Dispatch Card */}
diff --git a/src/app/call/new/index.tsx b/src/app/call/new/index.tsx
index cd609b7..90d804e 100644
--- a/src/app/call/new/index.tsx
+++ b/src/app/call/new/index.tsx
@@ -13,6 +13,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as z from 'zod';
import { createCall } from '@/api/calls/calls';
+import { getNewCallData } from '@/api/dispatch/dispatch';
import { getNewCallForm } from '@/api/forms/forms';
import { saveUdfValues } from '@/api/userDefinedFields/userDefinedFields';
import { CallFormRenderer } from '@/components/calls/call-form-renderer';
@@ -37,9 +38,11 @@ import { Text } from '@/components/ui/text';
import { Textarea, TextareaInput } from '@/components/ui/textarea';
import { useAnalytics } from '@/hooks/use-analytics';
import { useToast } from '@/hooks/use-toast';
+import { getPoiDestinationOptionLabel } from '@/lib/poi-display';
import { type CallResultData } from '@/models/v4/calls/callResultData';
import { type ContactResultData } from '@/models/v4/contacts/contactResultData';
import { type FormResultData } from '@/models/v4/forms/formResultData';
+import { type PoiResultData } from '@/models/v4/mapping/poiResultData';
import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput';
import { useCoreStore } from '@/stores/app/core-store';
import { useCallsStore } from '@/stores/calls/store';
@@ -50,6 +53,7 @@ const formSchema = z.object({
name: z.string().min(1, { message: 'Name is required' }),
nature: z.string().min(1, { message: 'Nature is required' }),
note: z.string().optional(),
+ destinationPoiId: z.string().optional(),
address: z.string().optional(),
coordinates: z.string().optional(),
what3words: z.string().optional(),
@@ -73,6 +77,8 @@ const formSchema = z.object({
type FormValues = z.infer;
+const NO_DESTINATION_VALUE = '__none__';
+
// Google Maps Geocoding API response types
interface GeocodingResult {
formatted_address: string;
@@ -130,6 +136,8 @@ export default function NewCall() {
const [showLinkedCallsModal, setShowLinkedCallsModal] = useState(false);
const [callFormData, setCallFormData] = useState(null);
const [callForm, setCallForm] = useState(null);
+ const [destinationPois, setDestinationPois] = useState([]);
+ const [isLoadingDestinationPois, setIsLoadingDestinationPois] = useState(false);
const [udfValues, setUdfValues] = useState([]);
const [selectedProtocols, setSelectedProtocols] = useState([]);
const [linkedCall, setLinkedCall] = useState<{ callId: string; number: string; name: string } | null>(null);
@@ -181,6 +189,7 @@ export default function NewCall() {
name: '',
nature: '',
note: '',
+ destinationPoiId: '',
address: '',
coordinates: '',
what3words: '',
@@ -214,6 +223,30 @@ export default function NewCall() {
.catch(() => {});
}, []);
+ useEffect(() => {
+ let isMounted = true;
+
+ setIsLoadingDestinationPois(true);
+ getNewCallData()
+ .then((result) => {
+ if (isMounted) {
+ setDestinationPois(result?.Data?.DestinationPois || []);
+ }
+ })
+ .catch((error) => {
+ console.error('Failed to load destination POIs:', error);
+ })
+ .finally(() => {
+ if (isMounted) {
+ setIsLoadingDestinationPois(false);
+ }
+ });
+
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
// Track when new call view is rendered
useEffect(() => {
trackEvent('new_call_view_rendered', {
@@ -252,6 +285,7 @@ export default function NewCall() {
priority: priority.Id,
type: type.Name,
note: data.note,
+ destinationPoiId: data.destinationPoiId ? Number(data.destinationPoiId) : null,
address: data.address,
latitude: data.latitude,
longitude: data.longitude,
@@ -952,6 +986,35 @@ export default function NewCall() {
)}
+
+
+
+ {t('calls.destination_poi')}
+
+ (
+
+ )}
+ />
+ {isLoadingDestinationPois ? {t('calls.loading_destination_pois')} : null}
+ {!isLoadingDestinationPois && destinationPois.length === 0 ? {t('calls.no_destination_pois_available')} : null}
+
) : null}
diff --git a/src/app/call/new/index.web.tsx b/src/app/call/new/index.web.tsx
index 2431085..de44125 100644
--- a/src/app/call/new/index.web.tsx
+++ b/src/app/call/new/index.web.tsx
@@ -10,6 +10,7 @@ import { Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 're
import * as z from 'zod';
import { createCall } from '@/api/calls/calls';
+import { getNewCallData } from '@/api/dispatch/dispatch';
import { getNewCallForm } from '@/api/forms/forms';
import { saveUdfValues } from '@/api/userDefinedFields/userDefinedFields';
import { CallFormRenderer } from '@/components/calls/call-form-renderer';
@@ -31,9 +32,11 @@ import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { useAnalytics } from '@/hooks/use-analytics';
import { useToast } from '@/hooks/use-toast';
+import { getPoiDestinationOptionLabel } from '@/lib/poi-display';
import { type CallResultData } from '@/models/v4/calls/callResultData';
import { type ContactResultData } from '@/models/v4/contacts/contactResultData';
import { type FormResultData } from '@/models/v4/forms/formResultData';
+import { type PoiResultData } from '@/models/v4/mapping/poiResultData';
import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput';
import { useCoreStore } from '@/stores/app/core-store';
import { useCallsStore } from '@/stores/calls/store';
@@ -44,6 +47,7 @@ const formSchema = z.object({
name: z.string().min(1, { message: 'Name is required' }),
nature: z.string().min(1, { message: 'Nature is required' }),
note: z.string().optional(),
+ destinationPoiId: z.string().optional(),
address: z.string().optional(),
coordinates: z.string().optional(),
what3words: z.string().optional(),
@@ -68,6 +72,8 @@ const formSchema = z.object({
type FormValues = z.infer;
+const NO_DESTINATION_VALUE = '__none__';
+
// Google Maps Geocoding API response types
interface GeocodingResult {
formatted_address: string;
@@ -220,9 +226,10 @@ interface WebSelectProps {
options: Array<{ id: string | number; name: string; color?: string }>;
error?: string;
required?: boolean;
+ useIdValue?: boolean;
}
-const WebSelect: React.FC = ({ label, placeholder, value, onChange, options, error, required = false }) => {
+const WebSelect: React.FC = ({ label, placeholder, value, onChange, options, error, required = false, useIdValue = false }) => {
const { colorScheme } = useColorScheme();
const isDark = colorScheme === 'dark';
@@ -243,7 +250,7 @@ const WebSelect: React.FC = ({ label, placeholder, value, onChan
>
) : (
diff --git a/src/components/dispatch-console/personnel-panel.tsx b/src/components/dispatch-console/personnel-panel.tsx
index 43de7b1..683323f 100644
--- a/src/components/dispatch-console/personnel-panel.tsx
+++ b/src/components/dispatch-console/personnel-panel.tsx
@@ -1,5 +1,5 @@
import { type Href, router } from 'expo-router';
-import { Building2, Circle, ExternalLink, Filter, Phone, Plus, Search, User, Users, X } from 'lucide-react-native';
+import { Circle, ExternalLink, Filter, MapPin, Plus, Search, User, Users, X } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -71,7 +71,7 @@ const PersonnelItem: React.FC<{
) : null}
{hasDestination ? (
-
+
{person.StatusDestinationName}
diff --git a/src/components/dispatch-console/unit-actions-panel.tsx b/src/components/dispatch-console/unit-actions-panel.tsx
index 998cfb2..711cd94 100644
--- a/src/components/dispatch-console/unit-actions-panel.tsx
+++ b/src/components/dispatch-console/unit-actions-panel.tsx
@@ -1,10 +1,9 @@
-import { Building2, Check, ChevronDown, ChevronRight, ChevronUp, Phone, Send, Truck, X } from 'lucide-react-native';
+import { Building2, Check, ChevronDown, ChevronRight, ChevronUp, MapPinned, Phone, Send, Truck, X } from 'lucide-react-native';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native';
-import { getAllGroups } from '@/api/groups/groups';
-import { getAllUnitStatuses } from '@/api/satuses';
+import { getSetUnitStatusData } from '@/api/dispatch/dispatch';
import { UdfFieldsRenderer } from '@/components/calls/udf-fields-renderer';
import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet';
import { Box } from '@/components/ui/box';
@@ -14,9 +13,12 @@ import { Icon } from '@/components/ui/icon';
import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
+import { type DestinationTab, getDefaultDestinationTab, getDestinationCapabilities } from '@/lib/destination-helpers';
+import { getPoiSelectionLabel } from '@/lib/poi-display';
import { invertColor, isCallActive } from '@/lib/utils';
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 StatusesResultData } from '@/models/v4/statuses/statusesResultData';
import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData';
import { useCallsStore } from '@/stores/calls/store';
@@ -68,16 +70,18 @@ const StatusSheetOption: React.FC<{
// Destination option for the action sheet
const DestinationSheetOption: React.FC<{
- type: 'call' | 'station' | 'none';
- item?: CallResultData | GroupResultData;
+ type: 'call' | 'station' | 'poi' | 'none';
+ item?: CallResultData | GroupResultData | PoiResultData;
isSelected: boolean;
onSelect: () => void;
label?: string;
}> = ({ type, item, isSelected, onSelect, label }) => {
const isCall = type === 'call';
+ const isPoi = type === 'poi';
const isNone = type === 'none';
const call = isCall && item ? (item as CallResultData) : null;
- const station = !isCall && !isNone && item ? (item as GroupResultData) : null;
+ const poi = isPoi && item ? (item as PoiResultData) : null;
+ const station = !isCall && !isPoi && !isNone && item ? (item as GroupResultData) : null;
return (
@@ -87,9 +91,13 @@ const DestinationSheetOption: React.FC<{
}`}
>
- {isNone ? : }
+ {isNone ? (
+
+ ) : (
+
+ )}
- {isNone ? label : isCall ? `#${call?.Number} - ${call?.Name}` : station?.Name}
+ {isNone ? label : isCall ? `#${call?.Number} - ${call?.Name}` : isPoi ? getPoiSelectionLabel(poi!) : station?.Name}
{isSelected ? : null}
@@ -109,7 +117,7 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
const [isStatusSheetOpen, setIsStatusSheetOpen] = useState(false);
const [isDestinationSheetOpen, setIsDestinationSheetOpen] = useState(false);
const [isAdditionalFieldsExpanded, setIsAdditionalFieldsExpanded] = useState(false);
- const [destinationTab, setDestinationTab] = useState<'calls' | 'stations'>('calls');
+ const [destinationTab, setDestinationTab] = useState('calls');
// Local state for selected status (to fix synchronization issues)
const [localSelectedStatus, setLocalSelectedStatus] = useState(null);
@@ -121,10 +129,12 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
statusDestinationType,
statusSelectedCall,
statusSelectedStation,
+ statusSelectedPoi,
statusNote,
isSubmittingStatus,
availableStatuses,
availableStations,
+ availablePois,
isLoadingOptions,
statusError,
closeActions,
@@ -132,11 +142,13 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
setStatusDestinationType,
setStatusSelectedCall,
setStatusSelectedStation,
+ setStatusSelectedPoi,
setStatusNote,
submitStatus: storeSubmitStatus,
setAvailableStatuses,
setAvailableCalls,
setAvailableStations,
+ setAvailablePois,
setIsLoadingOptions,
} = useUnitActionsStore();
@@ -162,28 +174,16 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
// Load options when panel opens
useEffect(() => {
+ if (!selectedUnit) return;
+
const loadOptions = async () => {
setIsLoadingOptions(true);
try {
- const [statusesResult, groupsResult] = await Promise.all([getAllUnitStatuses(), getAllGroups()]);
-
- if (statusesResult?.Data && statusesResult.Data.length > 0) {
- // Unit statuses come grouped by unit type, we need to extract the statuses
- // For now, use the first unit type's statuses or flatten all
- const allStatuses: StatusesResultData[] = [];
- statusesResult.Data.forEach((unitType) => {
- if (unitType.Statuses) {
- allStatuses.push(...unitType.Statuses);
- }
- });
- // Remove duplicates by Id
- const uniqueStatuses = allStatuses.filter((status, index, self) => index === self.findIndex((s) => s.Id === status.Id));
- setAvailableStatuses(uniqueStatuses);
- }
- if (groupsResult?.Data) {
- const stations = groupsResult.Data.filter((g) => g.GroupType?.toLowerCase().includes('station'));
- setAvailableStations(stations.length > 0 ? stations : groupsResult.Data);
- }
+ const unitStatusData = await getSetUnitStatusData(selectedUnit.UnitId);
+ setAvailableStatuses((unitStatusData?.Data?.Statuses as unknown as StatusesResultData[]) || []);
+ setAvailableCalls(unitStatusData?.Data?.Calls || []);
+ setAvailableStations(unitStatusData?.Data?.Stations || []);
+ setAvailablePois(unitStatusData?.Data?.DestinationPois || []);
} catch (error) {
console.error('Failed to load unit action options:', error);
} finally {
@@ -194,7 +194,7 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
if (selectedUnit) {
loadOptions();
}
- }, [selectedUnit, setAvailableStatuses, setAvailableStations, setIsLoadingOptions]);
+ }, [selectedUnit, setAvailableStatuses, setAvailableCalls, setAvailableStations, setAvailablePois, setIsLoadingOptions]);
// Update available calls from calls store
useEffect(() => {
@@ -245,11 +245,19 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
return;
}
+ const matchingPoi = availablePois.find((poi) => poi.PoiId.toString() === destinationId);
+ if (matchingPoi) {
+ setStatusDestinationType('poi');
+ setStatusSelectedPoi(matchingPoi);
+ lastInitializedUnitIdRef.current = selectedUnit.UnitId;
+ return;
+ }
+
// If we couldn't match but have data loaded, mark as initialized anyway
- if (calls.length > 0 || availableStations.length > 0) {
+ if (calls.length > 0 || availableStations.length > 0 || availablePois.length > 0) {
lastInitializedUnitIdRef.current = selectedUnit.UnitId;
}
- }, [selectedUnit, calls, availableStations, setStatusDestinationType, setStatusSelectedCall, setStatusSelectedStation]);
+ }, [selectedUnit, calls, availableStations, availablePois, setStatusDestinationType, setStatusSelectedCall, setStatusSelectedStation, setStatusSelectedPoi]);
const handleSubmitStatus = useCallback(async () => {
// Pass current unit and status directly to avoid state sync issues
@@ -271,22 +279,15 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
if (statusDestinationType === 'station' && statusSelectedStation) {
return statusSelectedStation.Name;
}
+ if (statusDestinationType === 'poi' && statusSelectedPoi) {
+ return getPoiSelectionLabel(statusSelectedPoi);
+ }
return t('dispatch.unit_actions_panel.no_destination');
- }, [statusDestinationType, statusSelectedCall, statusSelectedStation, t]);
+ }, [statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, t]);
// Check destination type allowed based on Detail
- // Detail: 0 = No destination needed, 1 = Station only, 2 = Call only, 3 = Both
const destinationConfig = useMemo(() => {
- if (!selectedStatus) {
- return { showStations: true, showCalls: true };
- }
- if (selectedStatus.Detail === 0) {
- return { showStations: true, showCalls: true };
- }
- return {
- showStations: selectedStatus.Detail === 1 || selectedStatus.Detail === 3,
- showCalls: selectedStatus.Detail === 2 || selectedStatus.Detail === 3,
- };
+ return getDestinationCapabilities(selectedStatus?.Detail);
}, [selectedStatus]);
// Check note requirement based on Note field
@@ -312,6 +313,12 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
return calls.filter((c) => isCallActive(c.State));
}, [calls]);
+ useEffect(() => {
+ if (selectedStatus) {
+ setDestinationTab(getDefaultDestinationTab(selectedStatus.Detail));
+ }
+ }, [selectedStatus]);
+
// Refresh calls when destination sheet opens
useEffect(() => {
if (isDestinationSheetOpen) {
@@ -324,21 +331,24 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
setSelectedStatus(status);
setIsStatusSheetOpen(false);
// If status requires destination, open destination sheet
- if (status.Detail > 0) {
+ if (getDestinationCapabilities(status.Detail).supportsDestination) {
setTimeout(() => setIsDestinationSheetOpen(true), 300);
}
};
// Handle destination selection
- const handleDestinationSelect = (type: 'none' | 'call' | 'station', item?: CallResultData | GroupResultData) => {
+ const handleDestinationSelect = (type: 'none' | 'call' | 'station' | 'poi', item?: CallResultData | GroupResultData | PoiResultData) => {
if (type === 'none') {
setStatusDestinationType('none');
setStatusSelectedCall(null);
setStatusSelectedStation(null);
+ setStatusSelectedPoi(null);
} else if (type === 'call' && item) {
setStatusSelectedCall(item as CallResultData);
} else if (type === 'station' && item) {
setStatusSelectedStation(item as GroupResultData);
+ } else if (type === 'poi' && item) {
+ setStatusSelectedPoi(item as PoiResultData);
}
setIsDestinationSheetOpen(false);
};
@@ -401,11 +411,11 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
{/* Destination Button (only show if status is selected and supports destination) */}
- {selectedStatus && selectedStatus.Detail > 0 ? (
+ {selectedStatus && destinationConfig.supportsDestination ? (
setIsDestinationSheetOpen(true)}>
-
+
{t('dispatch.unit_actions_panel.destination')}
@@ -490,7 +500,7 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
handleDestinationSelect('none')} label={t('dispatch.unit_actions_panel.no_destination')} />
{/* Tabs for Calls and Stations */}
- {destinationConfig.showCalls || destinationConfig.showStations ? (
+ {destinationConfig.showCalls || destinationConfig.showStations || destinationConfig.showPois ? (
<>
{destinationConfig.showCalls ? (
@@ -507,6 +517,13 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
) : null}
+ {destinationConfig.showPois ? (
+ setDestinationTab('pois')} className={`flex-1 rounded-md px-3 py-2 ${destinationTab === 'pois' ? 'bg-white shadow-sm dark:bg-gray-700' : ''}`}>
+
+ {t('menu.pois')} ({availablePois.length})
+
+
+ ) : null}
{/* Tab Content */}
@@ -548,6 +565,24 @@ export const UnitActionsPanel: React.FC
= ({ unit: unitPr
)}
) : null}
+
+ {destinationTab === 'pois' && destinationConfig.showPois ? (
+
+ {availablePois.length > 0 ? (
+ availablePois.map((poi) => (
+ handleDestinationSelect('poi', poi)}
+ />
+ ))
+ ) : (
+ {t('status.no_pois_available')}
+ )}
+
+ ) : null}
>
) : (
diff --git a/src/components/dispatch-console/units-panel.tsx b/src/components/dispatch-console/units-panel.tsx
index 8f4331f..2b9032d 100644
--- a/src/components/dispatch-console/units-panel.tsx
+++ b/src/components/dispatch-console/units-panel.tsx
@@ -1,5 +1,5 @@
import { type Href, router } from 'expo-router';
-import { Building2, Circle, ExternalLink, Filter, MapPin, Phone, Plus, Search, Truck, X } from 'lucide-react-native';
+import { Circle, ExternalLink, Filter, MapPin, Plus, Search, Truck, X } from 'lucide-react-native';
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native';
@@ -80,7 +80,7 @@ const UnitItem: React.FC<{
{hasDestination ? (
-
+
{unit.CurrentDestinationName}
diff --git a/src/components/maps/__tests__/pin-actions.test.tsx b/src/components/maps/__tests__/pin-actions.test.tsx
index 12061f4..3b35cb9 100644
--- a/src/components/maps/__tests__/pin-actions.test.tsx
+++ b/src/components/maps/__tests__/pin-actions.test.tsx
@@ -152,7 +152,15 @@ const mockCallPin = {
Type: 1,
InfoWindowContent: 'Medical emergency at Main St',
Color: '#ff0000',
- zIndex: '1',
+ zIndex: 1,
+ PoiImage: '',
+ Marker: '',
+ PoiTypeId: null,
+ PoiTypeName: '',
+ Address: '',
+ Note: '',
+ LayerId: '',
+ LayerName: '',
};
const mockUnitPin = {
@@ -164,7 +172,15 @@ const mockUnitPin = {
Type: 2,
InfoWindowContent: 'Engine 1 available',
Color: '#00ff00',
- zIndex: '1',
+ zIndex: 1,
+ PoiImage: '',
+ Marker: '',
+ PoiTypeId: null,
+ PoiTypeName: '',
+ Address: '',
+ Note: '',
+ LayerId: '',
+ LayerName: '',
};
describe('Pin Actions Integration Tests', () => {
@@ -531,7 +547,15 @@ describe('Pin Actions Integration Tests', () => {
Type: 0,
InfoWindowContent: '',
Color: '',
- zIndex: '1',
+ zIndex: 0,
+ PoiImage: '',
+ Marker: '',
+ PoiTypeId: null,
+ PoiTypeName: '',
+ Address: '',
+ Note: '',
+ LayerId: '',
+ LayerName: '',
};
render(
diff --git a/src/components/maps/map-pins.tsx b/src/components/maps/map-pins.tsx
index 64fd252..5baf377 100644
--- a/src/components/maps/map-pins.tsx
+++ b/src/components/maps/map-pins.tsx
@@ -1,26 +1,34 @@
import Mapbox from '@rnmapbox/maps';
import React from 'react';
-import { type MAP_ICONS } from '@/constants/map-icons';
+import { isPoiMarker } from '@/lib/destination-helpers';
+import { hasValidMapCoordinates } from '@/lib/map-markers';
import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
import PinMarker from './pin-marker';
-type MapIconKey = keyof typeof MAP_ICONS;
-
interface MapPinsProps {
pins: MapMakerInfoData[];
onPinPress?: (pin: MapMakerInfoData) => void;
}
+/**
+ * Anchor point for markers:
+ * - POI markers (36x48): anchor [18, 48] = {x: 0.5, y: 1.0} (bottom-center)
+ * - Non-POI markers (32x37): anchor [16, 37] = {x: 0.5, y: 1.0} (bottom-center)
+ */
+const BOTTOM_CENTER_ANCHOR = { x: 0.5, y: 1.0 };
+
const MapPins: React.FC = ({ pins, onPinPress }) => {
return (
<>
- {pins.map((pin) => (
-
- onPinPress?.(pin)} />
-
- ))}
+ {pins
+ .filter((pin) => hasValidMapCoordinates(pin))
+ .map((pin) => (
+
+ onPinPress?.(pin)} />
+
+ ))}
>
);
};
diff --git a/src/components/maps/pin-detail-modal.tsx b/src/components/maps/pin-detail-modal.tsx
index a35a6eb..36106da 100644
--- a/src/components/maps/pin-detail-modal.tsx
+++ b/src/components/maps/pin-detail-modal.tsx
@@ -1,5 +1,5 @@
import { type Href, useRouter } from 'expo-router';
-import { MapPinIcon, PhoneIcon, RouteIcon, XIcon } from 'lucide-react-native';
+import { Building2Icon, MapPinIcon, PhoneIcon, RouteIcon, XIcon } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React from 'react';
import { useTranslation } from 'react-i18next';
@@ -13,6 +13,8 @@ import { HStack } from '@/components/ui/hstack';
import { Pressable } from '@/components/ui/pressable';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
+import { isCallMarker, isPoiMarker } from '@/lib/destination-helpers';
+import { getMapPinSummary, hasValidMapCoordinates } from '@/lib/map-markers';
import { openMapsWithDirections } from '@/lib/navigation';
import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
import { useLocationStore } from '@/stores/app/location-store';
@@ -37,10 +39,18 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC
if (!pin) return null;
- const isCallPin = pin.ImagePath?.toLowerCase() === 'call' || pin.Type === 1;
+ const isCallPin = isCallMarker(pin.Type, pin.ImagePath);
+ const isPoiPin = isPoiMarker({
+ type: pin.Type,
+ poiTypeId: pin.PoiTypeId,
+ layerId: pin.LayerId,
+ imagePath: pin.ImagePath,
+ poiImage: pin.PoiImage,
+ });
+ const summaryText = getMapPinSummary(pin);
const handleRouteToLocation = async () => {
- if (!pin.Latitude || !pin.Longitude) {
+ if (!hasValidMapCoordinates(pin)) {
showToast('error', t('map.no_location_for_routing'));
return;
}
@@ -63,6 +73,13 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC
}
};
+ const handleViewPoiDetails = () => {
+ if (isPoiPin && pin.Id) {
+ router.push(`/poi/${pin.Id}` as Href);
+ onClose();
+ }
+ };
+
const handleSetAsCurrentCall = () => {
if (isCallPin && onSetAsCurrentCall) {
onSetAsCurrentCall(pin);
@@ -88,18 +105,18 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC
- {pin.InfoWindowContent && (
+ {summaryText ? (
- {pin.InfoWindowContent}
+ {summaryText}
- )}
+ ) : null}
- {pin.Color && (
+ {pin.Color ? (
{t('map.pin_color')}
- )}
+ ) : null}
@@ -112,7 +129,7 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC
{/* Call-specific actions */}
- {isCallPin && (
+ {isCallPin ? (
<>
>
- )}
+ ) : null}
+
+ {isPoiPin ? (
+
+ ) : null}
diff --git a/src/components/maps/pin-marker.tsx b/src/components/maps/pin-marker.tsx
index 67bba6c..ccc23d8 100644
--- a/src/components/maps/pin-marker.tsx
+++ b/src/components/maps/pin-marker.tsx
@@ -1,32 +1,116 @@
import type Mapbox from '@rnmapbox/maps';
import { useColorScheme } from 'nativewind';
import React from 'react';
-import { Image, StyleSheet, Text, TouchableOpacity } from 'react-native';
+import { Image, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import Svg, { Path } from 'react-native-svg';
import { MAP_ICONS } from '@/constants/map-icons';
+import { isPoiMarker } from '@/lib/destination-helpers';
+import { getMapMarkerColor, getPoiMarkerIconChar, getPoiMarkerShapePath, resolveMapMarkerIconKey } from '@/lib/map-markers';
+import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
type MapIconKey = keyof typeof MAP_ICONS;
interface PinMarkerProps {
- imagePath?: MapIconKey;
- title: string;
+ pin: MapMakerInfoData;
size?: number;
markerRef?: Mapbox.PointAnnotation | null;
onPress?: () => void;
}
-const PinMarker: React.FC = ({ imagePath, title, size = 32, onPress }) => {
+/**
+ * POI Marker dimensions per the reference document:
+ * - Total size: 36 x 48 pixels
+ * - viewBox for SVG shapes: "-24 -48 48 48"
+ * - Icon positioned: centered-X (left:50%, translateX:-50%), top: 10px
+ * - Icon font size: 14px, color: #ffffff
+ */
+const POI_MARKER_WIDTH = 36;
+const POI_MARKER_HEIGHT = 48;
+const POI_ICON_TOP_OFFSET = 10;
+const POI_ICON_FONT_SIZE = 14;
+
+const PinMarker: React.FC = ({ pin, size = 32, onPress }) => {
const { colorScheme } = useColorScheme();
- // Safely get the icon, falling back to 'call' icon if not found
- const iconKey = imagePath?.toLowerCase() as MapIconKey;
- const icon = (iconKey && MAP_ICONS[iconKey]) || MAP_ICONS['call'];
+ const isPoiMapPin = isPoiMarker({
+ type: pin.Type,
+ poiTypeId: pin.PoiTypeId,
+ layerId: pin.LayerId,
+ imagePath: pin.ImagePath,
+ poiImage: pin.PoiImage,
+ });
+
+ // Non-POI (legacy) icon resolution
+ const iconKey = resolveMapMarkerIconKey(pin) as MapIconKey;
+ const icon = MAP_ICONS[iconKey] || MAP_ICONS.call;
+
+ // POI marker properties
+ const poiColor = getMapMarkerColor(pin);
+ const poiShapePath = getPoiMarkerShapePath(pin.Marker);
+ const poiIconChar = getPoiMarkerIconChar(pin.PoiImage);
+
+ /**
+ * Scale factor: the SVG viewBox is 48x48, rendered into POI_MARKER_WIDTH x POI_MARKER_HEIGHT.
+ * scaleX = 36/48 = 0.75, scaleY = 48/48 = 1.0
+ */
+ const svgWidth = POI_MARKER_WIDTH;
+ const svgHeight = POI_MARKER_HEIGHT;
+
+ // Drop shadow style: offset(0,1px), blur=2px, rgba(17,24,39,0.35)
+ const shadowStyle = {
+ shadowColor: '#111827',
+ shadowOffset: { width: 0, height: 1 },
+ shadowOpacity: 0.35,
+ shadowRadius: 2,
+ elevation: 3,
+ };
+
+ // On native, the map-icons font Unicode characters (PUA range) won't render
+ // without the font bundled. We show the character anyway — if the font is
+ // bundled with the app under the "map-icons" family name, it will render.
+ // Otherwise the system will show a fallback glyph or nothing.
+ const poiIconTextStyle = {
+ position: 'absolute' as const,
+ top: POI_ICON_TOP_OFFSET,
+ left: '50%' as const,
+ transform: [{ translateX: -POI_ICON_FONT_SIZE / 2 }],
+ fontSize: POI_ICON_FONT_SIZE,
+ lineHeight: POI_ICON_FONT_SIZE,
+ color: '#ffffff',
+ fontFamily: Platform.OS === 'web' ? 'map-icons' : 'map-icons',
+ includeFontPadding: false,
+ textAlignVertical: 'center' as const,
+ };
+
+ if (isPoiMapPin) {
+ return (
+
+
+ {/* SVG background shape */}
+
+ {/* White font icon overlay */}
+
+ {poiIconChar}
+
+
+
+
+ {pin.Title}
+
+
+ );
+ }
+
+ // Non-POI legacy marker rendering
return (
- {title}
+ {pin.Title}
);
@@ -37,6 +121,16 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
+ poiContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ // POI shape wrapper — matches the reference document's 36x48px marker
+ poiShapeWrapper: {
+ position: 'relative',
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ },
image: {
overflow: 'visible',
resizeMode: 'cover',
diff --git a/src/components/maps/unified-map-view.web.tsx b/src/components/maps/unified-map-view.web.tsx
index 60e69ad..e69f103 100644
--- a/src/components/maps/unified-map-view.web.tsx
+++ b/src/components/maps/unified-map-view.web.tsx
@@ -5,9 +5,10 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { getMapDataAndMarkers } from '@/api/mapping/mapping';
-import { getMapIconWebUrl, MAP_ICONS } from '@/constants/map-icons';
import { Env } from '@/lib/env';
import { logger } from '@/lib/logging';
+import { getMapPinSummary, hasValidMapCoordinates } from '@/lib/map-markers';
+import { createMapMarkerElement } from '@/lib/map-markers-web';
import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
import { type GetMapLayersData } from '@/models/v4/mapping/getMapLayersResultData';
import { useLocationStore } from '@/stores/app/location-store';
@@ -15,8 +16,6 @@ import { useLocationStore } from '@/stores/app/location-store';
// Mapbox GL CSS needs to be injected for web
const MAPBOX_GL_CSS_URL = 'https://api.mapbox.com/mapbox-gl-js/v3.15.0/mapbox-gl.css';
-type MapIconKey = keyof typeof MAP_ICONS;
-
interface UnifiedMapViewProps {
/** Map pins to display */
pins?: MapMakerInfoData[];
@@ -220,57 +219,10 @@ export const UnifiedMapView: React.FC = ({
// Add new markers
mapPins.forEach((pin) => {
- if (!pin.Latitude || !pin.Longitude) return;
-
- // Create custom marker element
- const el = document.createElement('div');
- el.className = 'map-marker';
- el.style.display = 'flex';
- el.style.flexDirection = 'column';
- el.style.alignItems = 'center';
- el.style.cursor = 'pointer';
-
- // Create marker icon
- const iconContainer = document.createElement('div');
- iconContainer.style.width = '32px';
- iconContainer.style.height = '32px';
- iconContainer.style.position = 'relative';
-
- const iconKey = (pin.ImagePath?.toLowerCase() || 'call') as MapIconKey;
- const iconData = MAP_ICONS[iconKey] || MAP_ICONS['call'];
-
- const img = document.createElement('img');
- const imgSrc = getMapIconWebUrl(iconData);
- img.src = imgSrc;
- img.style.width = '32px';
- img.style.height = '32px';
- img.style.objectFit = 'contain';
- img.alt = pin.Title;
- img.onerror = () => {
- // Fallback to call icon if image fails to load
- img.src = getMapIconWebUrl(MAP_ICONS['call']);
- };
- iconContainer.appendChild(img);
-
- el.appendChild(iconContainer);
-
- // Create title label
- const title = document.createElement('div');
- title.textContent = pin.Title;
- title.style.fontSize = '10px';
- title.style.fontWeight = '600';
- title.style.textAlign = 'center';
- title.style.marginTop = '2px';
- title.style.maxWidth = '80px';
- title.style.overflow = 'hidden';
- title.style.textOverflow = 'ellipsis';
- title.style.whiteSpace = 'nowrap';
- title.style.color = colorScheme === 'dark' ? '#ffffff' : '#000000';
- title.style.textShadow = colorScheme === 'dark' ? '0 0 2px rgba(0,0,0,0.8)' : '0 0 2px rgba(255,255,255,0.8)';
- el.appendChild(title);
-
- // Add click handler
- el.addEventListener('click', () => {
+ if (!hasValidMapCoordinates(pin)) return;
+
+ // Create custom marker element using shared utility
+ const el = createMapMarkerElement(pin, colorScheme, () => {
onPinPress?.(pin);
});
@@ -278,7 +230,7 @@ export const UnifiedMapView: React.FC = ({
const popup = new mapboxgl.Popup({ offset: 25 }).setHTML(
`
${pin.Title}
- ${pin.InfoWindowContent ? `
${pin.InfoWindowContent}
` : ''}
+ ${getMapPinSummary(pin) ? `
${getMapPinSummary(pin)}
` : ''}
${pin.Latitude.toFixed(6)}, ${pin.Longitude.toFixed(6)}
diff --git a/src/components/pois/poi-card.tsx b/src/components/pois/poi-card.tsx
new file mode 100644
index 0000000..3621006
--- /dev/null
+++ b/src/components/pois/poi-card.tsx
@@ -0,0 +1,130 @@
+import { MapPin, MapPinned } from 'lucide-react-native';
+import { useColorScheme } from 'nativewind';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Pressable, StyleSheet, Text, View } from 'react-native';
+
+import { Badge, BadgeText } from '@/components/ui/badge';
+import { getPoiPrimaryDisplayText, getPoiSecondaryDisplayText } from '@/lib/poi-display';
+import { type PoiResultData } from '@/models/v4/mapping/poiResultData';
+
+interface PoiCardProps {
+ poi: PoiResultData;
+ onPress: (poi: PoiResultData) => void;
+}
+
+export const PoiCard: React.FC
= ({ poi, onPress }) => {
+ const { t } = useTranslation();
+ const { colorScheme } = useColorScheme();
+ const isDark = colorScheme === 'dark';
+ const primaryText = getPoiPrimaryDisplayText(poi);
+ const secondaryText = getPoiSecondaryDisplayText(poi);
+ const accentStyle = StyleSheet.flatten([styles.accent, { backgroundColor: poi.Color || '#2563eb' }]);
+
+ const cardStyle = StyleSheet.flatten([styles.card, isDark ? styles.cardDark : styles.cardLight]);
+ const titleColor = isDark ? '#f9fafb' : '#111827';
+ const secondaryColor = isDark ? '#9ca3af' : '#6b7280';
+ const detailColor = isDark ? '#d1d5db' : '#4b5563';
+ const iconColor = isDark ? '#9ca3af' : '#6b7280';
+
+ return (
+ onPress(poi)}>
+
+
+
+
+ {primaryText || t('pois.unnamed')}
+ {poi.PoiTypeName || t('pois.unknown_type')}
+
+
+ {poi.IsDestination ? (
+
+ {t('pois.destination')}
+
+ ) : null}
+
+
+ {secondaryText ? (
+
+
+
+ {secondaryText}
+
+
+ ) : null}
+
+ {poi.Note ? (
+
+
+
+ {poi.Note}
+
+
+ ) : null}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ card: {
+ marginBottom: 12,
+ borderRadius: 12,
+ padding: 16,
+ shadowColor: '#000000',
+ shadowOffset: { width: 0, height: 1 },
+ shadowOpacity: 0.08,
+ shadowRadius: 2,
+ elevation: 2,
+ },
+ cardLight: {
+ backgroundColor: '#ffffff',
+ },
+ cardDark: {
+ backgroundColor: '#1f2937',
+ },
+ cardHeader: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ justifyContent: 'space-between',
+ marginBottom: 10,
+ },
+ titleContainer: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ marginRight: 12,
+ },
+ titleTextContainer: {
+ flex: 1,
+ },
+ accent: {
+ width: 10,
+ height: 10,
+ borderRadius: 999,
+ marginRight: 12,
+ marginTop: 6,
+ },
+ titleText: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ typeText: {
+ fontSize: 13,
+ marginTop: 2,
+ },
+ detailRow: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ marginTop: 6,
+ },
+ detailText: {
+ flex: 1,
+ fontSize: 14,
+ marginLeft: 8,
+ },
+ noteText: {
+ flex: 1,
+ fontSize: 13,
+ marginLeft: 8,
+ },
+});
diff --git a/src/components/pois/poi-detail-screen.tsx b/src/components/pois/poi-detail-screen.tsx
new file mode 100644
index 0000000..944f4d2
--- /dev/null
+++ b/src/components/pois/poi-detail-screen.tsx
@@ -0,0 +1,239 @@
+import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
+import { MapPin, Navigation, StickyNote } from 'lucide-react-native';
+import React, { useEffect, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { ScrollView, StyleSheet, Text, 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 { Badge, BadgeText } from '@/components/ui/badge';
+import { Box } from '@/components/ui/box';
+import { Button, ButtonIcon, ButtonText } from '@/components/ui/button';
+import { Card } from '@/components/ui/card';
+import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
+import { Heading } from '@/components/ui/heading';
+import { openMapsWithDirections } from '@/lib/navigation';
+import { getPoiPrimaryDisplayText } from '@/lib/poi-display';
+import { useLocationStore } from '@/stores/app/location-store';
+import { usePoisStore } from '@/stores/pois/store';
+import { useToastStore } from '@/stores/toast/store';
+
+export const PoiDetailScreen: React.FC = () => {
+ const { id } = useLocalSearchParams();
+ const router = useRouter();
+ const { t } = useTranslation();
+ const showToast = useToastStore((state) => state.showToast);
+ const { selectedPoi, isLoadingDetail, detailError, fetchPoi, resetSelectedPoi } = usePoisStore();
+ const userLocation = useLocationStore((state) => ({
+ latitude: state.latitude,
+ longitude: state.longitude,
+ }));
+
+ const poiId = useMemo(() => {
+ const rawId = Array.isArray(id) ? id[0] : id;
+ const parsedId = Number(rawId);
+ return Number.isFinite(parsedId) && parsedId > 0 ? parsedId : null;
+ }, [id]);
+
+ useEffect(() => {
+ if (poiId !== null) {
+ fetchPoi(poiId, true);
+ }
+
+ return () => {
+ resetSelectedPoi();
+ };
+ }, [fetchPoi, poiId, resetSelectedPoi]);
+
+ const handleRoute = async () => {
+ if (!selectedPoi?.Latitude || !selectedPoi?.Longitude) {
+ showToast('error', t('pois.no_location_for_routing'));
+ return;
+ }
+
+ const success = await openMapsWithDirections(selectedPoi.Latitude, selectedPoi.Longitude, getPoiPrimaryDisplayText(selectedPoi), userLocation.latitude || undefined, userLocation.longitude || undefined);
+
+ if (!success) {
+ showToast('error', t('pois.route_error'));
+ }
+ };
+
+ if (poiId === null) {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+ }
+
+ if (isLoadingDetail && !selectedPoi) {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+ }
+
+ if (detailError || !selectedPoi) {
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+ }
+
+ const title = getPoiPrimaryDisplayText(selectedPoi) || t('pois.unnamed');
+ const hasCoordinates = Number.isFinite(selectedPoi.Latitude) && Number.isFinite(selectedPoi.Longitude) && !(selectedPoi.Latitude === 0 && selectedPoi.Longitude === 0);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {title}
+ {selectedPoi.PoiTypeName || t('pois.unknown_type')}
+
+ {selectedPoi.IsDestination ? (
+
+ {t('pois.destination')}
+
+ ) : null}
+
+
+ {selectedPoi.Address ? (
+
+
+ {selectedPoi.Address}
+
+ ) : null}
+
+
+
+
+
+ {t('pois.map')}
+ {hasCoordinates ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+
+
+ {t('pois.details')}
+
+
+ {t('pois.type')}
+ {selectedPoi.PoiTypeName || t('pois.unknown_type')}
+
+
+
+ {t('pois.address')}
+ {selectedPoi.Address || t('common.not_available')}
+
+
+
+ {t('pois.note')}
+
+
+ {selectedPoi.Note || t('common.not_available')}
+
+
+
+
+
+
+ >
+ );
+};
+
+const styles = StyleSheet.create({
+ screen: {
+ flex: 1,
+ backgroundColor: '#f9fafb',
+ },
+ content: {
+ padding: 16,
+ paddingBottom: 32,
+ },
+ headerRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'flex-start',
+ },
+ headerTextContainer: {
+ flex: 1,
+ marginRight: 12,
+ },
+ subtitleText: {
+ color: '#6b7280',
+ fontSize: 14,
+ marginTop: 4,
+ },
+ infoRow: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ marginTop: 12,
+ },
+ infoText: {
+ color: '#374151',
+ flex: 1,
+ marginLeft: 10,
+ fontSize: 14,
+ },
+ sectionTitle: {
+ color: '#111827',
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: 12,
+ },
+ detailBlock: {
+ marginBottom: 16,
+ },
+ detailLabel: {
+ color: '#6b7280',
+ fontSize: 12,
+ fontWeight: '600',
+ marginBottom: 6,
+ textTransform: 'uppercase',
+ },
+ detailValue: {
+ color: '#111827',
+ fontSize: 14,
+ lineHeight: 20,
+ },
+ noteRow: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ },
+});
diff --git a/src/components/sidebar/__tests__/side-menu.test.tsx b/src/components/sidebar/__tests__/side-menu.test.tsx
index 4986891..6168bc7 100644
--- a/src/components/sidebar/__tests__/side-menu.test.tsx
+++ b/src/components/sidebar/__tests__/side-menu.test.tsx
@@ -14,6 +14,7 @@ jest.mock('react-i18next', () => ({
'menu.calls_list': 'Calls List',
'menu.new_call': 'New Call',
'menu.map': 'Map',
+ 'menu.pois': 'POIs',
'menu.personnel': 'Personnel',
'menu.units': 'Units',
'menu.messages': 'Messages',
@@ -57,6 +58,7 @@ describe('SideMenu', () => {
expect(screen.getByText('Home')).toBeTruthy();
expect(screen.getByText('Calls')).toBeTruthy();
expect(screen.getByText('Map')).toBeTruthy();
+ expect(screen.getByText('POIs')).toBeTruthy();
expect(screen.getByText('Personnel')).toBeTruthy();
expect(screen.getByText('Units')).toBeTruthy();
expect(screen.getByText('Protocols')).toBeTruthy();
@@ -87,6 +89,19 @@ describe('SideMenu', () => {
});
});
+ it('should navigate to the POIs route when the POIs item is pressed', async () => {
+ const mockOnNavigate = jest.fn();
+ render();
+
+ const poisItem = screen.getByText('POIs');
+ fireEvent.press(poisItem);
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith('/pois');
+ expect(mockOnNavigate).toHaveBeenCalled();
+ });
+ });
+
it('should expand parent menu items to show children', async () => {
render();
@@ -141,6 +156,7 @@ describe('SideMenu', () => {
expect(screen.getByText('Home')).toBeTruthy();
expect(screen.getByText('Calls')).toBeTruthy();
expect(screen.getByText('Map')).toBeTruthy();
+ expect(screen.getByText('POIs')).toBeTruthy();
expect(screen.getByText('Personnel')).toBeTruthy();
expect(screen.getByText('Units')).toBeTruthy();
expect(screen.getByText('Protocols')).toBeTruthy();
@@ -161,4 +177,4 @@ describe('SideMenu', () => {
const container = screen.getByTestId('side-menu-scroll-view');
expect(container).toBeTruthy();
});
-});
\ No newline at end of file
+});
diff --git a/src/components/sidebar/side-menu.tsx b/src/components/sidebar/side-menu.tsx
index 342736e..07feabe 100644
--- a/src/components/sidebar/side-menu.tsx
+++ b/src/components/sidebar/side-menu.tsx
@@ -1,5 +1,5 @@
import { type Href, useRouter } from 'expo-router';
-import { CloudLightning, Contact, FileText, Home, List, type LucideIcon, Map as MapIcon, MessageCircle, Phone, Plus, Settings, Truck, Users } from 'lucide-react-native';
+import { CalendarClock, CloudLightning, Contact, FileText, Home, List, type LucideIcon, Map as MapIcon, MapPinned, MessageCircle, Phone, Plus, Settings, Truck, Users } from 'lucide-react-native';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
@@ -25,9 +25,11 @@ const getMenuItems = (t: (key: string) => string): MenuItem[] => [
icon: Phone,
children: [
{ id: 'calls-list', label: t('menu.calls_list'), icon: List, route: '/calls' },
+ { id: 'scheduled-calls', label: t('menu.scheduled_calls'), icon: CalendarClock, route: '/scheduled-calls' },
{ id: 'new-call', label: t('menu.new_call'), icon: Plus, route: '/call/new' },
],
},
+ { id: 'pois', label: t('menu.pois'), icon: MapPinned, route: '/pois' },
{ id: 'map', label: t('menu.map'), icon: MapIcon, route: '/map' },
{ id: 'personnel', label: t('menu.personnel'), icon: Users, route: '/personnel' },
{ id: 'units', label: t('menu.units'), icon: Truck, route: '/units' },
diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx
index 8b0d57b..5759d69 100644
--- a/src/components/status/status-bottom-sheet.tsx
+++ b/src/components/status/status-bottom-sheet.tsx
@@ -4,6 +4,8 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView, TouchableOpacity } from 'react-native';
+import { DestinationEntityType, type DestinationTab, getDefaultDestinationTab, getDestinationCapabilities, getEnabledDestinationTabs } from '@/lib/destination-helpers';
+import { getPoiSelectionLabel } from '@/lib/poi-display';
import { invertColor } from '@/lib/utils';
import { type CustomStatusResultData } from '@/models/v4/customStatuses/customStatusResultData';
import { SaveUnitStatusInput, SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput';
@@ -25,7 +27,7 @@ import { VStack } from '../ui/vstack';
export const StatusBottomSheet = () => {
const { t } = useTranslation();
const { colorScheme } = useColorScheme();
- const [selectedTab, setSelectedTab] = React.useState<'calls' | 'stations'>('calls');
+ const [selectedTab, setSelectedTab] = React.useState('calls');
const [isSubmitting, setIsSubmitting] = React.useState(false);
const hasPreselectedRef = React.useRef(false);
const { showToast } = useToastStore();
@@ -35,17 +37,20 @@ export const StatusBottomSheet = () => {
currentStep,
selectedCall,
selectedStation,
+ selectedPoi,
selectedDestinationType,
selectedStatus,
cameFromStatusSelection,
note,
availableCalls,
availableStations,
+ availablePois,
isLoading,
setIsOpen,
setCurrentStep,
setSelectedCall,
setSelectedStation,
+ setSelectedPoi,
setSelectedDestinationType,
setSelectedStatus,
setNote,
@@ -82,6 +87,7 @@ export const StatusBottomSheet = () => {
setSelectedCall(call);
setSelectedDestinationType('call');
setSelectedStation(null);
+ setSelectedPoi(null);
}
};
@@ -91,6 +97,17 @@ export const StatusBottomSheet = () => {
setSelectedStation(station);
setSelectedDestinationType('station');
setSelectedCall(null);
+ setSelectedPoi(null);
+ }
+ };
+
+ const handlePoiSelect = (poiId: number) => {
+ const poi = availablePois.find((currentPoi) => currentPoi.PoiId === poiId);
+ if (poi) {
+ setSelectedPoi(poi);
+ setSelectedDestinationType('poi');
+ setSelectedCall(null);
+ setSelectedStation(null);
}
};
@@ -98,6 +115,7 @@ export const StatusBottomSheet = () => {
setSelectedDestinationType('none');
setSelectedCall(null);
setSelectedStation(null);
+ setSelectedPoi(null);
};
const handleNext = () => {
@@ -172,8 +190,16 @@ export const StatusBottomSheet = () => {
// Set RespondingTo based on destination selection
if (selectedDestinationType === 'call' && selectedCall) {
input.RespondingTo = selectedCall.CallId;
+ input.RespondingToType = DestinationEntityType.Call;
} else if (selectedDestinationType === 'station' && selectedStation) {
input.RespondingTo = selectedStation.GroupId;
+ input.RespondingToType = DestinationEntityType.Station;
+ } else if (selectedDestinationType === 'poi' && selectedPoi) {
+ input.RespondingTo = selectedPoi.PoiId.toString();
+ input.RespondingToType = DestinationEntityType.Poi;
+ } else {
+ input.RespondingTo = '';
+ input.RespondingToType = null;
}
// Include GPS coordinates if available
@@ -228,6 +254,7 @@ export const StatusBottomSheet = () => {
selectedDestinationType,
selectedCall,
selectedStation,
+ selectedPoi,
unitRoleAssignments,
saveUnitStatus,
reset,
@@ -252,6 +279,14 @@ export const StatusBottomSheet = () => {
}
}, [isOpen, activeUnit, selectedStatus, fetchDestinationData]);
+ React.useEffect(() => {
+ if (!selectedStatus) {
+ return;
+ }
+
+ setSelectedTab(getDefaultDestinationTab(selectedStatus.Detail));
+ }, [selectedStatus]);
+
// Pre-select active call when opening with calls enabled
React.useLayoutEffect(() => {
// Reset the pre-selection flag when bottom sheet closes
@@ -262,7 +297,9 @@ export const StatusBottomSheet = () => {
// Immediate pre-selection: if we have the conditions met, pre-select right away
// This runs on every render to catch the case where availableCalls loads in
- if (isOpen && selectedStatus && (selectedStatus.Detail === 2 || selectedStatus.Detail === 3) && activeCallId && !selectedCall && selectedDestinationType === 'none' && !hasPreselectedRef.current) {
+ const capabilities = getDestinationCapabilities(selectedStatus?.Detail);
+
+ if (isOpen && selectedStatus && capabilities.showCalls && activeCallId && !selectedCall && selectedDestinationType === 'none' && !hasPreselectedRef.current) {
// Check if we have calls available (loaded) or should wait
if (!isLoading && availableCalls.length > 0) {
const activeCall = availableCalls.find((call) => call.CallId === activeCallId);
@@ -282,7 +319,7 @@ export const StatusBottomSheet = () => {
// Handle case where destination type is already 'call' but call hasn't been set yet
// This covers the scenario from the removed redundant effect
- if (isOpen && selectedStatus && (selectedStatus.Detail === 2 || selectedStatus.Detail === 3) && activeCallId && !selectedCall && selectedDestinationType === 'call' && !isLoading && availableCalls.length > 0) {
+ if (isOpen && selectedStatus && capabilities.showCalls && activeCallId && !selectedCall && selectedDestinationType === 'call' && !isLoading && availableCalls.length > 0) {
const activeCall = availableCalls.find((call) => call.CallId === activeCallId);
if (activeCall) {
setSelectedCall(activeCall);
@@ -294,12 +331,12 @@ export const StatusBottomSheet = () => {
// Don't show it as selected if we're about to pre-select an active call or already have one selected
const shouldShowNoDestinationAsSelected = React.useMemo(() => {
// If something else is already selected, don't show no destination as selected
- if (selectedCall || selectedStation) {
+ if (selectedCall || selectedStation || selectedPoi) {
return false;
}
// If we're in a state where we should pre-select an active call, don't show no destination as selected
- const shouldPreSelectActiveCall = isOpen && selectedStatus && (selectedStatus.Detail === 2 || selectedStatus.Detail === 3) && activeCallId && !selectedCall;
+ const shouldPreSelectActiveCall = isOpen && selectedStatus && getDestinationCapabilities(selectedStatus.Detail).showCalls && activeCallId && !selectedCall;
if (shouldPreSelectActiveCall) {
return false;
@@ -307,14 +344,38 @@ export const StatusBottomSheet = () => {
// Otherwise, show it as selected only if explicitly set to 'none'
return selectedDestinationType === 'none';
- }, [selectedDestinationType, selectedCall, selectedStation, isOpen, selectedStatus, activeCallId]);
+ }, [selectedDestinationType, selectedCall, selectedStation, selectedPoi, isOpen, selectedStatus, activeCallId]);
// Determine step logic
const detailLevel = getStatusProperty('Detail', 0);
- const shouldShowDestinationStep = detailLevel > 0;
+ const destinationCapabilities = getDestinationCapabilities(detailLevel);
+ const enabledDestinationTabs = getEnabledDestinationTabs(detailLevel);
+ const shouldShowDestinationStep = destinationCapabilities.supportsDestination;
const noteType = getStatusProperty('Note', 0);
const isNoteRequired = noteType === 2; // NoteType 2 = required
const isNoteOptional = noteType === 1; // NoteType 1 = optional
+ const shouldShowTabHeaders = enabledDestinationTabs.length > 1;
+
+ const getStatusDetailDescription = (detail: number) => {
+ switch (detail) {
+ case 1:
+ return t('status.station_destination_enabled');
+ case 2:
+ return t('status.call_destination_enabled');
+ case 3:
+ return t('status.both_destinations_enabled');
+ case 4:
+ return t('status.poi_destination_enabled');
+ case 5:
+ return t('status.calls_and_pois_destination_enabled');
+ case 6:
+ return t('status.stations_and_pois_destination_enabled');
+ case 7:
+ return t('status.all_destinations_enabled');
+ default:
+ return '';
+ }
+ };
const getStepTitle = () => {
switch (currentStep) {
@@ -416,6 +477,10 @@ export const StatusBottomSheet = () => {
return selectedStation.Name;
}
+ if (selectedPoi) {
+ return getPoiSelectionLabel(selectedPoi);
+ }
+
// Then check destination type for other scenarios
if (selectedDestinationType === 'call') {
if (activeCallId) {
@@ -476,13 +541,7 @@ export const StatusBottomSheet = () => {
{status.Text}
- {status.Detail > 0 && (
-
- {status.Detail === 1 && t('status.station_destination_enabled')}
- {status.Detail === 2 && t('status.call_destination_enabled')}
- {status.Detail === 3 && t('status.both_destinations_enabled')}
-
- )}
+ {status.Detail > 0 && {getStatusDetailDescription(status.Detail)}}
{status.Note > 0 && (
{status.Note === 1 && t('status.note_optional')}
@@ -526,25 +585,30 @@ export const StatusBottomSheet = () => {
- {/* Show tabs only if we have both calls and stations to choose from */}
- {((detailLevel === 1 && availableStations.length > 0) || (detailLevel === 2 && availableCalls.length > 0) || (detailLevel === 3 && (availableCalls.length > 0 || availableStations.length > 0))) && (
+ {enabledDestinationTabs.length > 0 && (
<>
- {/* Tab Headers - only show if we have both types or multiple options */}
- {detailLevel === 3 && (
+ {shouldShowTabHeaders && (
- setSelectedTab('calls')} className={`flex-1 rounded-lg py-3 ${selectedTab === 'calls' ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`}>
- {t('status.calls_tab')}
-
- setSelectedTab('stations')} className={`flex-1 rounded-lg py-3 ${selectedTab === 'stations' ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`}>
- {t('status.stations_tab')}
-
+ {enabledDestinationTabs.includes('calls') ? (
+ setSelectedTab('calls')} className={`flex-1 rounded-lg py-3 ${selectedTab === 'calls' ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`}>
+ {t('status.calls_tab')}
+
+ ) : null}
+ {enabledDestinationTabs.includes('stations') ? (
+ setSelectedTab('stations')} className={`flex-1 rounded-lg py-3 ${selectedTab === 'stations' ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`}>
+ {t('status.stations_tab')}
+
+ ) : null}
+ {enabledDestinationTabs.includes('pois') ? (
+ setSelectedTab('pois')} className={`flex-1 rounded-lg py-3 ${selectedTab === 'pois' ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`}>
+ {t('status.pois_tab')}
+
+ ) : null}
)}
- {/* Tab Content */}
-
- {/* Show calls if detailLevel 2 or 3, and either no tabs or calls tab selected */}
- {(detailLevel === 2 || (detailLevel === 3 && selectedTab === 'calls')) && (
+
+ {enabledDestinationTabs.includes('calls') && (!shouldShowTabHeaders || selectedTab === 'calls') && (
{isLoading ? (
@@ -575,8 +639,7 @@ export const StatusBottomSheet = () => {
)}
- {/* Show stations if detailLevel 1 or 3, and either no tabs or stations tab selected */}
- {(detailLevel === 1 || (detailLevel === 3 && selectedTab === 'stations')) && (
+ {enabledDestinationTabs.includes('stations') && (!shouldShowTabHeaders || selectedTab === 'stations') && (
{isLoading ? (
@@ -605,6 +668,36 @@ export const StatusBottomSheet = () => {
)}
)}
+
+ {enabledDestinationTabs.includes('pois') && (!shouldShowTabHeaders || selectedTab === 'pois') && (
+
+ {isLoading ? (
+
+
+ {t('status.loading_pois')}
+
+ ) : availablePois && availablePois.length > 0 ? (
+ availablePois.map((poi) => (
+ handlePoiSelect(poi.PoiId)}
+ className={`mb-3 rounded-lg border-2 p-3 ${selectedPoi?.PoiId === poi.PoiId ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'}`}
+ >
+
+
+
+ {getPoiSelectionLabel(poi)}
+ {poi.Note ? {poi.Note} : null}
+ {poi.PoiTypeName ? {poi.PoiTypeName} : null}
+
+
+
+ ))
+ ) : (
+ {t('status.no_pois_available')}
+ )}
+
+ )}
>
)}
diff --git a/src/hooks/__tests__/use-map-signalr-updates.test.ts b/src/hooks/__tests__/use-map-signalr-updates.test.ts
index 53151a8..c991673 100644
--- a/src/hooks/__tests__/use-map-signalr-updates.test.ts
+++ b/src/hooks/__tests__/use-map-signalr-updates.test.ts
@@ -37,16 +37,25 @@ describe('useMapSignalRUpdates', () => {
Latitude: 40.7128,
Longitude: -74.0060,
Title: 'Test Marker',
- zIndex: '1',
+ zIndex: 1,
ImagePath: 'test-icon',
InfoWindowContent: 'Test content',
Color: 'red',
Type: 1,
+ PoiImage: '',
+ Marker: '',
+ PoiTypeId: null,
+ PoiTypeName: '',
+ LayerId: '',
+ Address: '',
+ Note: '',
+ LayerName: '',
},
] as MapMakerInfoData[],
CenterLat: '40.7128',
CenterLon: '-74.0060',
ZoomLevel: '12',
+ PoiLayers: [],
},
};
diff --git a/src/hooks/use-map-signalr-updates.ts b/src/hooks/use-map-signalr-updates.ts
index 72764b0..cda8504 100644
--- a/src/hooks/use-map-signalr-updates.ts
+++ b/src/hooks/use-map-signalr-updates.ts
@@ -3,12 +3,13 @@ import { useCallback, useEffect, useRef } from 'react';
import { getMapDataAndMarkers } from '@/api/mapping/mapping';
import { logger } from '@/lib/logging';
import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
+import { type PoiLayerData } from '@/models/v4/mapping/poiLayerData';
import { useSignalRStore } from '@/stores/signalr/signalr-store';
// Debounce delay in milliseconds to prevent rapid consecutive API calls
const DEBOUNCE_DELAY = 1000;
-export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData[]) => void) => {
+export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData[]) => void, onPoiLayersUpdate?: (poiLayers: PoiLayerData[]) => void) => {
const lastProcessedTimestamp = useRef(0);
const isUpdating = useRef(false);
const pendingTimestamp = useRef(null);
@@ -67,6 +68,10 @@ export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData
});
onMarkersUpdate(mapDataAndMarkers.Data.MapMakerInfos);
+
+ if (onPoiLayersUpdate) {
+ onPoiLayersUpdate(mapDataAndMarkers.Data.PoiLayers ?? []);
+ }
}
// Update the last processed timestamp after successful API call
@@ -112,7 +117,7 @@ export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData
}
}
},
- [lastUpdateTimestamp, onMarkersUpdate]
+ [lastUpdateTimestamp, onMarkersUpdate, onPoiLayersUpdate]
);
useEffect(() => {
diff --git a/src/lib/__tests__/destination-helpers.test.ts b/src/lib/__tests__/destination-helpers.test.ts
new file mode 100644
index 0000000..2b0eeee
--- /dev/null
+++ b/src/lib/__tests__/destination-helpers.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from '@jest/globals';
+
+import {
+ CustomStateDetailType,
+ DestinationEntityType,
+ MapMarkerEntityType,
+ getDefaultDestinationTab,
+ getDestinationCapabilities,
+ getDestinationSelectionTypeFromValue,
+ getDestinationSelectionTypeValue,
+ getEnabledDestinationTabs,
+ getSelectedDestinationId,
+ isCallMarker,
+ isPoiDestinationType,
+} from '../destination-helpers';
+
+describe('destination-helpers', () => {
+ it('returns the correct capabilities for POI-enabled status details', () => {
+ expect(getDestinationCapabilities(CustomStateDetailType.CallsAndPois)).toEqual({
+ showCalls: true,
+ showStations: false,
+ showPois: true,
+ supportsDestination: true,
+ });
+ });
+
+ it('derives enabled tabs and default tabs from detail values', () => {
+ expect(getEnabledDestinationTabs(CustomStateDetailType.CallsStationsAndPois)).toEqual(['calls', 'stations', 'pois']);
+ expect(getDefaultDestinationTab(CustomStateDetailType.Pois)).toBe('pois');
+ expect(getDefaultDestinationTab(undefined)).toBe('calls');
+ });
+
+ it('maps POI destination types to and from API values', () => {
+ expect(getDestinationSelectionTypeValue('poi')).toBe(DestinationEntityType.Poi);
+ expect(getDestinationSelectionTypeFromValue(DestinationEntityType.Poi)).toBe('poi');
+ expect(isPoiDestinationType(DestinationEntityType.Poi)).toBe(true);
+ });
+
+ it('resolves selected POI destination ids', () => {
+ expect(
+ getSelectedDestinationId({
+ selectedDestinationType: 'poi',
+ selectedCall: null,
+ selectedStation: null,
+ selectedPoi: {
+ PoiId: 42,
+ } as any,
+ })
+ ).toBe('42');
+ });
+
+ it('recognizes call markers from marker type or image token', () => {
+ expect(isCallMarker(MapMarkerEntityType.Call)).toBe(true);
+ expect(isCallMarker(undefined, 'call')).toBe(true);
+ });
+});
diff --git a/src/lib/__tests__/poi-display.test.ts b/src/lib/__tests__/poi-display.test.ts
new file mode 100644
index 0000000..4e8d39f
--- /dev/null
+++ b/src/lib/__tests__/poi-display.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from '@jest/globals';
+
+import { getPoiDestinationOptionLabel, getPoiPrimaryDisplayText, getPoiSearchValue, getPoiSelectionLabel } from '../poi-display';
+
+describe('poi-display', () => {
+ it('prefers name then address then note then type for primary display', () => {
+ expect(getPoiPrimaryDisplayText({ Name: 'Mercy Hospital', Address: '123 Main St', Note: 'ER entrance', PoiTypeName: 'Hospital' } as any)).toBe('Mercy Hospital');
+ expect(getPoiPrimaryDisplayText({ Name: '', Address: '123 Main St', Note: 'ER entrance', PoiTypeName: 'Hospital' } as any)).toBe('123 Main St');
+ expect(getPoiPrimaryDisplayText({ Name: '', Address: '', Note: 'ER entrance', PoiTypeName: 'Hospital' } as any)).toBe('ER entrance');
+ expect(getPoiPrimaryDisplayText({ Name: '', Address: '', Note: '', PoiTypeName: 'Hospital' } as any)).toBe('Hospital');
+ });
+
+ it('builds selection labels from name and address when available', () => {
+ expect(getPoiSelectionLabel({ Name: 'Mercy Hospital', Address: '123 Main St', Note: '', PoiTypeName: 'Hospital' } as any)).toBe('Mercy Hospital - 123 Main St');
+ expect(getPoiSelectionLabel({ Name: '', Address: '123 Main St', Note: '', PoiTypeName: 'Hospital' } as any)).toBe('123 Main St');
+ });
+
+ it('builds destination option labels with the type name prefix', () => {
+ expect(getPoiDestinationOptionLabel({ Name: 'Mercy Hospital', Address: '123 Main St', Note: '', PoiTypeName: 'Hospital' } as any)).toBe('Hospital: Mercy Hospital - 123 Main St');
+ });
+
+ it('builds a searchable lower-case value from all visible fields', () => {
+ expect(getPoiSearchValue({ Name: 'Mercy Hospital', Address: '123 Main St', Note: 'ER Entrance', PoiTypeName: 'Hospital' } as any)).toBe('mercy hospital 123 main st er entrance hospital');
+ });
+});
diff --git a/src/lib/destination-helpers.ts b/src/lib/destination-helpers.ts
new file mode 100644
index 0000000..81ee015
--- /dev/null
+++ b/src/lib/destination-helpers.ts
@@ -0,0 +1,170 @@
+import { type CallResultData } from '@/models/v4/calls/callResultData';
+import { type GroupResultData } from '@/models/v4/groups/groupsResultData';
+import { type PoiResultData } from '@/models/v4/mapping/poiResultData';
+
+export enum DestinationEntityType {
+ None = 0,
+ Station = 1,
+ Call = 2,
+ Poi = 3,
+}
+
+export enum CustomStateDetailType {
+ None = 0,
+ Stations = 1,
+ Calls = 2,
+ CallsAndStations = 3,
+ Pois = 4,
+ CallsAndPois = 5,
+ StationsAndPois = 6,
+ CallsStationsAndPois = 7,
+}
+
+export enum MapMarkerEntityType {
+ Call = 0,
+ Unit = 1,
+ Station = 2,
+ Personnel = 3,
+ Poi = 4,
+}
+
+export type DestinationSelectionType = 'none' | 'call' | 'station' | 'poi';
+export type DestinationTab = 'calls' | 'stations' | 'pois';
+
+export interface DestinationCapabilities {
+ showCalls: boolean;
+ showStations: boolean;
+ showPois: boolean;
+ supportsDestination: boolean;
+}
+
+export const getDestinationCapabilities = (detail?: number | null): DestinationCapabilities => {
+ switch (detail) {
+ case CustomStateDetailType.Stations:
+ return { showCalls: false, showStations: true, showPois: false, supportsDestination: true };
+ case CustomStateDetailType.Calls:
+ return { showCalls: true, showStations: false, showPois: false, supportsDestination: true };
+ case CustomStateDetailType.CallsAndStations:
+ return { showCalls: true, showStations: true, showPois: false, supportsDestination: true };
+ case CustomStateDetailType.Pois:
+ return { showCalls: false, showStations: false, showPois: true, supportsDestination: true };
+ case CustomStateDetailType.CallsAndPois:
+ return { showCalls: true, showStations: false, showPois: true, supportsDestination: true };
+ case CustomStateDetailType.StationsAndPois:
+ return { showCalls: false, showStations: true, showPois: true, supportsDestination: true };
+ case CustomStateDetailType.CallsStationsAndPois:
+ return { showCalls: true, showStations: true, showPois: true, supportsDestination: true };
+ default:
+ return { showCalls: false, showStations: false, showPois: false, supportsDestination: false };
+ }
+};
+
+export const getDestinationSelectionTypeValue = (type: DestinationSelectionType): DestinationEntityType | null => {
+ switch (type) {
+ case 'call':
+ return DestinationEntityType.Call;
+ case 'station':
+ return DestinationEntityType.Station;
+ case 'poi':
+ return DestinationEntityType.Poi;
+ default:
+ return null;
+ }
+};
+
+export const getDestinationSelectionTypeFromValue = (type?: number | null): DestinationSelectionType => {
+ switch (type) {
+ case DestinationEntityType.Call:
+ return 'call';
+ case DestinationEntityType.Station:
+ return 'station';
+ case DestinationEntityType.Poi:
+ return 'poi';
+ default:
+ return 'none';
+ }
+};
+
+export const getDestinationTypeLabel = (type: DestinationSelectionType): string => {
+ switch (type) {
+ case 'call':
+ return 'call';
+ case 'station':
+ return 'station';
+ case 'poi':
+ return 'poi';
+ default:
+ return 'none';
+ }
+};
+
+export const isCallDestinationType = (type?: number | null) => type === DestinationEntityType.Call;
+
+export const isStationDestinationType = (type?: number | null) => type === DestinationEntityType.Station;
+
+export const isPoiDestinationType = (type?: number | null) => type === DestinationEntityType.Poi;
+
+export const isCallMarker = (type?: number | null, imagePath?: string | null) => type === MapMarkerEntityType.Call || imagePath?.toLowerCase() === 'call';
+
+export const isPoiMarker = (params: { type?: number | null; poiTypeId?: number | null; layerId?: string | null; imagePath?: string | null; poiImage?: string | null }): boolean => {
+ const { type, poiTypeId, layerId, imagePath, poiImage } = params;
+
+ // Condition 1: Explicit POI type (Type === 4)
+ if (type === MapMarkerEntityType.Poi) return true;
+
+ // Condition 2: PoiTypeId is a number greater than 0
+ if (typeof poiTypeId === 'number' && poiTypeId > 0) return true;
+
+ // Condition 3: LayerId starts with "poi-type-"
+ if (layerId && typeof layerId === 'string' && layerId.startsWith('poi-type-')) return true;
+
+ // Condition 4: PoiImage or ImagePath starts with "map-icon-" (case-insensitive)
+ const iconField = poiImage || imagePath;
+ if (iconField && typeof iconField === 'string' && iconField.toLowerCase().startsWith('map-icon-')) return true;
+
+ return false;
+};
+
+export const getEnabledDestinationTabs = (detail?: number | null): DestinationTab[] => {
+ const capabilities = getDestinationCapabilities(detail);
+ const tabs: DestinationTab[] = [];
+
+ if (capabilities.showCalls) {
+ tabs.push('calls');
+ }
+
+ if (capabilities.showStations) {
+ tabs.push('stations');
+ }
+
+ if (capabilities.showPois) {
+ tabs.push('pois');
+ }
+
+ return tabs;
+};
+
+export const getDefaultDestinationTab = (detail?: number | null): DestinationTab => {
+ const tabs = getEnabledDestinationTabs(detail);
+ return tabs[0] ?? 'calls';
+};
+
+export interface DestinationSelectionState {
+ selectedDestinationType: DestinationSelectionType;
+ selectedCall: CallResultData | null;
+ selectedStation: GroupResultData | null;
+ selectedPoi: PoiResultData | null;
+}
+
+export const getSelectedDestinationId = ({ selectedDestinationType, selectedCall, selectedStation, selectedPoi }: DestinationSelectionState): string => {
+ switch (selectedDestinationType) {
+ case 'call':
+ return selectedCall?.CallId ?? '';
+ case 'station':
+ return selectedStation?.GroupId ?? '';
+ case 'poi':
+ return selectedPoi ? selectedPoi.PoiId.toString() : '';
+ default:
+ return '';
+ }
+};
diff --git a/src/lib/map-markers-web.ts b/src/lib/map-markers-web.ts
new file mode 100644
index 0000000..4f81ce7
--- /dev/null
+++ b/src/lib/map-markers-web.ts
@@ -0,0 +1,197 @@
+import { getMapIconWebUrl, MAP_ICONS } from '@/constants/map-icons';
+import { isPoiMarker } from '@/lib/destination-helpers';
+import { getMapMarkerColor, getPoiMarkerIconChar, getPoiMarkerShapePath, resolveMapMarkerIconKey } from '@/lib/map-markers';
+import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
+
+type MapIconKey = keyof typeof MAP_ICONS;
+
+/**
+ * Injects the map-icons @font-face CSS into the document head if not already present.
+ * The map-icons font files are expected at /clib/mapMarkers/ on the server.
+ */
+let mapIconsFontInjected = false;
+
+export const injectMapIconsFont = (baseUrl = '/clib/mapMarkers/'): void => {
+ if (mapIconsFontInjected || typeof document === 'undefined') return;
+
+ const styleId = 'map-icons-font-face';
+ if (document.getElementById(styleId)) {
+ mapIconsFontInjected = true;
+ return;
+ }
+
+ const style = document.createElement('style');
+ style.id = styleId;
+ style.textContent = `
+ @font-face {
+ font-family: 'map-icons';
+ src: url('${baseUrl}map-icons.eot');
+ src: url('${baseUrl}map-icons.eot#iefix') format('embedded-opentype'),
+ url('${baseUrl}map-icons.ttf') format('truetype'),
+ url('${baseUrl}map-icons.woff') format('woff'),
+ url('${baseUrl}map-icons.svg#map-icons') format('svg');
+ font-weight: normal;
+ font-style: normal;
+ }
+
+ .map-icon {
+ font-family: 'map-icons';
+ speak: none;
+ font-style: normal;
+ font-weight: normal;
+ font-variant: normal;
+ text-transform: none;
+ line-height: 1;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ pointer-events: none;
+ }
+ `;
+ document.head.appendChild(style);
+ mapIconsFontInjected = true;
+};
+
+// ---------------------------------------------------------------------------
+// POI Marker Dimensions (per reference document)
+// ---------------------------------------------------------------------------
+const POI_MARKER_WIDTH = 36;
+const POI_MARKER_HEIGHT = 48;
+const POI_ICON_TOP_OFFSET = 10;
+const POI_ICON_FONT_SIZE = 14;
+
+/**
+ * Creates the POI marker DOM element matching the web app's structure:
+ *
+ *
+ */
+const createPoiMarkerElement = (pin: MapMakerInfoData): HTMLElement => {
+ const color = getMapMarkerColor(pin);
+ const shapePath = getPoiMarkerShapePath(pin.Marker);
+ const iconClass = pin.PoiImage || 'map-icon-map-pin';
+
+ const wrapper = document.createElement('div');
+ wrapper.className = 'rg-map__poi-marker';
+ wrapper.style.setProperty('--rg-map-poi-color', color);
+ wrapper.style.width = `${POI_MARKER_WIDTH}px`;
+ wrapper.style.height = `${POI_MARKER_HEIGHT}px`;
+ wrapper.style.position = 'relative';
+ wrapper.style.display = 'flex';
+ wrapper.style.alignItems = 'center';
+ wrapper.style.justifyContent = 'flex-start';
+ wrapper.style.flexDirection = 'column';
+
+ // SVG shape
+ const svgNS = 'http://www.w3.org/2000/svg';
+ const svg = document.createElementNS(svgNS, 'svg');
+ svg.setAttribute('viewBox', '-24 -48 48 48');
+ svg.setAttribute('width', `${POI_MARKER_WIDTH}`);
+ svg.setAttribute('height', `${POI_MARKER_HEIGHT}`);
+ svg.classList.add('rg-map__poi-marker-shape');
+ svg.style.filter = 'drop-shadow(0 1px 2px rgba(17, 24, 39, 0.35))';
+
+ const path = document.createElementNS(svgNS, 'path');
+ path.setAttribute('d', shapePath);
+ path.setAttribute('fill', color);
+
+ svg.appendChild(path);
+ wrapper.appendChild(svg);
+
+ // Font icon span
+ const iconSpan = document.createElement('span');
+ const glyph = getPoiMarkerIconChar(iconClass);
+ iconSpan.className = `map-icon ${iconClass}`;
+ iconSpan.classList.add('rg-map__poi-marker-icon');
+ iconSpan.textContent = glyph;
+ iconSpan.style.position = 'absolute';
+ iconSpan.style.top = `${POI_ICON_TOP_OFFSET}px`;
+ iconSpan.style.left = '50%';
+ iconSpan.style.transform = 'translateX(-50%)';
+ iconSpan.style.fontSize = `${POI_ICON_FONT_SIZE}px`;
+ iconSpan.style.lineHeight = '1';
+ iconSpan.style.color = '#ffffff';
+ iconSpan.style.pointerEvents = 'none';
+ iconSpan.setAttribute('aria-hidden', 'true');
+ wrapper.appendChild(iconSpan);
+
+ return wrapper;
+};
+
+/**
+ * Creates a marker DOM element that can be used with Mapbox GL JS.
+ * Handles both POI markers (SVG shape + map-icons font) and
+ * non-POI legacy markers (PNG images).
+ */
+export const createMapMarkerElement = (pin: MapMakerInfoData, colorScheme: 'dark' | 'light' = 'light', onClick?: () => void): HTMLElement => {
+ const isPoi = isPoiMarker({
+ type: pin.Type,
+ poiTypeId: pin.PoiTypeId,
+ layerId: pin.LayerId,
+ imagePath: pin.ImagePath,
+ poiImage: pin.PoiImage,
+ });
+
+ // Ensure map-icons font is loaded
+ injectMapIconsFont();
+
+ const el = document.createElement('div');
+ el.className = 'map-marker';
+ el.style.display = 'flex';
+ el.style.flexDirection = 'column';
+ el.style.alignItems = 'center';
+ el.style.cursor = 'pointer';
+
+ if (isPoi) {
+ // POI marker: SVG shape + map-icons font icon
+ const poiEl = createPoiMarkerElement(pin);
+ el.appendChild(poiEl);
+ } else {
+ // Non-POI legacy marker: PNG image
+ const iconContainer = document.createElement('div');
+ iconContainer.style.display = 'flex';
+ iconContainer.style.alignItems = 'center';
+ iconContainer.style.justifyContent = 'center';
+ iconContainer.style.width = '32px';
+ iconContainer.style.height = '32px';
+
+ const iconKey = resolveMapMarkerIconKey(pin) as MapIconKey;
+ const iconData = MAP_ICONS[iconKey] || MAP_ICONS['call'];
+ const img = document.createElement('img');
+ const imgSrc = getMapIconWebUrl(iconData);
+ img.src = imgSrc;
+ img.style.width = '32px';
+ img.style.height = '32px';
+ img.style.objectFit = 'contain';
+ img.alt = pin.Title;
+ img.onerror = () => {
+ img.src = getMapIconWebUrl(MAP_ICONS['call']);
+ };
+ iconContainer.appendChild(img);
+ el.appendChild(iconContainer);
+ }
+
+ // Title label
+ const title = document.createElement('div');
+ title.textContent = pin.Title;
+ title.style.fontSize = '10px';
+ title.style.fontWeight = '600';
+ title.style.textAlign = 'center';
+ title.style.marginTop = '2px';
+ title.style.maxWidth = '80px';
+ title.style.overflow = 'hidden';
+ title.style.textOverflow = 'ellipsis';
+ title.style.whiteSpace = 'nowrap';
+ title.style.color = colorScheme === 'dark' ? '#ffffff' : '#000000';
+ title.style.textShadow = colorScheme === 'dark' ? '0 0 2px rgba(0,0,0,0.8)' : '0 0 2px rgba(255,255,255,0.8)';
+ el.appendChild(title);
+
+ if (onClick) {
+ el.addEventListener('click', onClick);
+ }
+
+ return el;
+};
diff --git a/src/lib/map-markers.ts b/src/lib/map-markers.ts
new file mode 100644
index 0000000..f49825a
--- /dev/null
+++ b/src/lib/map-markers.ts
@@ -0,0 +1,328 @@
+import { MAP_ICONS } from '@/constants/map-icons';
+import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
+
+import { MapMarkerEntityType } from './destination-helpers';
+
+type MapIconKey = keyof typeof MAP_ICONS;
+
+// ---------------------------------------------------------------------------
+// POI Marker Shape Path Definitions (viewBox="-24 -48 48 48")
+// ---------------------------------------------------------------------------
+
+export const POI_MARKER_PATHS: Record = {
+ MAP_PIN: 'M0-48c-9.8 0-17.7 7.8-17.7 17.4 0 15.5 17.7 30.6 17.7 30.6s17.7-15.4 17.7-30.6c0-9.6-7.9-17.4-17.7-17.4z',
+
+ SHIELD:
+ 'M18.8-31.8c.3-3.4 1.3-6.6 3.2-9.5l-7-6.7c-2.2 1.8-4.8 2.8-7.6 3-2.6.2-5.1-.2-7.5-1.4-2.4 1.1-4.9 1.6-7.5 1.4-2.7-.2-5.1-1.1-7.3-2.7l-7.1 6.7c1.7 2.9 2.7 6 2.9 9.2.1 1.5-.3 3.5-1.3 6.1-.5 1.5-.9 2.7-1.2 3.8-.2 1-.4 1.9-.5 2.5 0 2.8.8 5.3 2.5 7.5 1.3 1.6 3.5 3.4 6.5 5.4 3.3 1.6 5.8 2.6 7.6 3.1.5.2 1 .4 1.5.7l1.5.6c1.2.7 2 1.4 2.4 2.1.5-.8 1.3-1.5 2.4-2.1.7-.3 1.3-.5 1.9-.8.5-.2.9-.4 1.1-.5.4-.1.9-.3 1.5-.6.6-.2 1.3-.5 2.2-.8 1.7-.6 3-1.1 3.8-1.6 2.9-2 5.1-3.8 6.4-5.3 1.7-2.2 2.6-4.8 2.5-7.6-.1-1.3-.7-3.3-1.7-6.1-.9-2.8-1.3-4.9-1.2-6.4z',
+
+ ROUTE:
+ 'M24-28.3c-.2-13.3-7.9-18.5-8.3-18.7l-1.2-.8-1.2.8c-2 1.4-4.1 2-6.1 2-3.4 0-5.8-1.9-5.9-1.9l-1.3-1.1-1.3 1.1c-.1.1-2.5 1.9-5.9 1.9-2.1 0-4.1-.7-6.1-2l-1.2-.8-1.2.8c-.8.6-8 5.9-8.2 18.7-.2 1.1 2.9 22.2 23.9 28.3 22.9-6.7 24.1-26.9 24-28.3z',
+
+ SQUARE: 'M-24-48h48v48h-48z',
+
+ SQUARE_ROUNDED: 'M24-8c0 4.4-3.6 8-8 8h-32c-4.4 0-8-3.6-8-8v-32c0-4.4 3.6-8 8-8h32c4.4 0 8 3.6 8 8v32z',
+};
+
+// ---------------------------------------------------------------------------
+// Resolving the POI marker shape path
+// ---------------------------------------------------------------------------
+
+/**
+ * Returns the SVG path data for the given marker shape.
+ * Normalizes the shape string to uppercase. Falls back to MAP_PIN
+ * when the shape is null, empty, or not found.
+ */
+export const getPoiMarkerShapePath = (markerShape?: string | null): string => {
+ const normalized = (markerShape ?? '').trim().toUpperCase();
+ if (!normalized) return POI_MARKER_PATHS['MAP_PIN'];
+ return POI_MARKER_PATHS[normalized] ?? POI_MARKER_PATHS['MAP_PIN'];
+};
+
+// ---------------------------------------------------------------------------
+// POI Marker Icon (map-icons font) Unicode mappings
+// ---------------------------------------------------------------------------
+
+/**
+ * Maps a map-icon CSS class name to the Unicode character code
+ * from the map-icons font.
+ * The keys are the full CSS class name (e.g. "map-icon-hospital").
+ * The values are the Unicode code points from map-icons.css.
+ * Default: "map-icon-map-pin" (\ue85d)
+ */
+const MAP_ICONS_UNICODE: Record = {
+ 'map-icon-map-pin': '\ue85d',
+ 'map-icon-point-of-interest': '\ue871',
+ 'map-icon-hospital': '\ue84b',
+ 'map-icon-police': '\ue872',
+ 'map-icon-fire-station': '\ue837',
+ 'map-icon-school': '\ue880',
+ 'map-icon-bank': '\ue80b',
+ 'map-icon-post-office': '\ue875',
+ 'map-icon-church': '\ue822',
+ 'map-icon-parking': '\ue86a',
+ 'map-icon-gas-station': '\ue840',
+ 'map-icon-airport': '\ue802',
+ 'map-icon-restaurant': '\ue87a',
+ 'map-icon-grocery-or-supermarket': '\ue843',
+ 'map-icon-pharmacy': '\ue86c',
+ 'map-icon-library': '\ue855',
+ 'map-icon-museum': '\ue864',
+ 'map-icon-stadium': '\ue892',
+ 'map-icon-courthouse': '\ue82a',
+ 'map-icon-city-hall': '\ue824',
+ 'map-icon-embassy': '\ue833',
+ 'map-icon-campground': '\ue819',
+ 'map-icon-park': '\ue869',
+ 'map-icon-lodging': '\ue85a',
+ 'map-icon-train-station': '\ue89d',
+ 'map-icon-bus-station': '\ue817',
+ 'map-icon-square-pin': '\ue88f',
+ 'map-icon-shield': '\ue883',
+ 'map-icon-route': '\ue87d',
+ 'map-icon-square': '\ue891',
+ 'map-icon-square-rounded': '\ue890',
+ 'map-icon-cafe': '\ue818',
+ 'map-icon-bar': '\ue80c',
+ 'map-icon-store': '\ue894',
+ 'map-icon-doctor': '\ue82e',
+ 'map-icon-dentist': '\ue82c',
+ 'map-icon-gym': '\ue844',
+ 'map-icon-spa': '\ue88e',
+ 'map-icon-pool': '\ue874',
+ 'map-icon-playground': '\ue870',
+ 'map-icon-golf': '\ue842',
+ 'map-icon-tennis': '\ue899',
+ 'map-icon-basketball': '\ue80e',
+ 'map-icon-baseball': '\ue80d',
+ 'map-icon-football-stadium': '\ue838',
+ 'map-icon-university': '\ue8a2',
+ 'map-icon-college': '\ue826',
+ 'map-icon-high-school': '\ue849',
+ 'map-icon-elementary-school': '\ue832',
+ 'map-icon-preschool': '\ue876',
+ 'map-icon-casino': '\ue81b',
+ 'map-icon-theater': '\ue89b',
+ 'map-icon-cinema': '\ue823',
+ 'map-icon-night-club': '\ue867',
+ 'map-icon-shopping-mall': '\ue887',
+ 'map-icon-department-store': '\ue82d',
+ 'map-icon-clothing-store': '\ue825',
+ 'map-icon-hardware-store': '\ue846',
+ 'map-icon-electronics-store': '\ue831',
+ 'map-icon-pet-store': '\ue86d',
+ 'map-icon-bakery': '\ue80a',
+ 'map-icon-butcher': '\ue816',
+ 'map-icon-florist': '\ue836',
+ 'map-icon-book-store': '\ue813',
+ 'map-icon-convenience-store': '\ue828',
+ 'map-icon-liquor-store': '\ue858',
+ 'map-icon-car-repair': '\ue81a',
+ 'map-icon-car-wash': '\ue81d',
+ 'map-icon-gas-station-garage': '\ue83f',
+ 'map-icon-plumber': '\ue873',
+ 'map-icon-electrician': '\ue830',
+ 'map-icon-locksmith': '\ue859',
+ 'map-icon-laundry': '\ue854',
+ 'map-icon-taxi-stand': '\ue897',
+ 'map-icon-transit-station': '\ue89e',
+ 'map-icon-subway-station': '\ue895',
+ 'map-icon-light-rail': '\ue857',
+ 'map-icon-ferry': '\ue835',
+ 'map-icon-marina': '\ue85c',
+ 'map-icon-harbor': '\ue845',
+ 'map-icon-lighthouse': '\ue856',
+ 'map-icon-monument': '\ue862',
+ 'map-icon-observatory': '\ue868',
+ 'map-icon-zoo': '\ue8a6',
+ 'map-icon-aquarium': '\ue805',
+ 'map-icon-amusement-park': '\ue803',
+ 'map-icon-water-park': '\ue8a3',
+ 'map-icon-attraction': '\ue807',
+ 'map-icon-beach': '\ue80f',
+ 'map-icon-lake': '\ue853',
+ 'map-icon-river': '\ue87c',
+ 'map-icon-mountain': '\ue863',
+ 'map-icon-skiing': '\ue889',
+ 'map-icon-skating': '\ue888',
+ 'map-icon-snowmobile': '\ue88c',
+ 'map-icon-snow': '\ue88b',
+ 'map-icon-sledding': '\ue88a',
+ 'map-icon-ice-fishing': '\ue84e',
+ 'map-icon-fishing': '\ue834',
+ 'map-icon-hunting': '\ue84d',
+ 'map-icon-hiking': '\ue84a',
+ 'map-icon-biking': '\ue811',
+ 'map-icon-walking': '\ue8a1',
+ 'map-icon-running': '\ue87f',
+ 'map-icon-horseback-riding': '\ue84c',
+ 'map-icon-boating': '\ue812',
+ 'map-icon-surfing': '\ue896',
+ 'map-icon-swimming': '\ue8a0',
+ 'map-icon-diving': '\ue82f',
+ 'map-icon-sailing': '\ue881',
+ 'map-icon-kayaking': '\ue852',
+ 'map-icon-rafting': '\ue878',
+ 'map-icon-camping': '\ue81c',
+ 'map-icon-tent': '\ue898',
+ 'map-icon-rv-park': '\ue87e',
+ 'map-icon-picnic': '\ue86e',
+ 'map-icon-bbq': '\ue810',
+ 'map-icon-fire-pit': '\ue839',
+ 'map-icon-toilet': '\ue89c',
+ 'map-icon-shower': '\ue886',
+ 'map-icon-water-fountain': '\ue8a4',
+ 'map-icon-bench': '\ue815',
+ 'map-icon-table': '\ue8a5',
+ 'map-icon-trash': '\ue89f',
+ 'map-icon-recycling': '\ue879',
+ 'map-icon-wheelchair': '\ue8a7',
+ 'map-icon-elevator': '\ue82b',
+ 'map-icon-escalator': '\ue8a8',
+ 'map-icon-stairs': '\ue893',
+ 'map-icon-ramp': '\ue87b',
+ 'map-icon-bridge': '\ue814',
+ 'map-icon-building': '\ue83c',
+ 'map-icon-house': '\ue83d',
+ 'map-icon-apartment': '\ue804',
+ 'map-icon-condo': '\ue827',
+ 'map-icon-cabin': '\ue820',
+ 'map-icon-farm': '\ue83a',
+ 'map-icon-barn': '\ue808',
+ 'map-icon-silo': '\ue88d',
+ 'map-icon-windmill': '\ue8aa',
+ 'map-icon-well': '\ue8ab',
+ 'map-icon-water-tower': '\ue8ac',
+ 'map-icon-cell-tower': '\ue821',
+ 'map-icon-satellite': '\ue882',
+ 'map-icon-antenna': '\ue806',
+ 'map-icon-radio-station': '\ue877',
+ 'map-icon-tv-station': '\ue8ad',
+ 'map-icon-news': '\ue866',
+ 'map-icon-cemetery': '\ue81e',
+ 'map-icon-crematorium': '\ue829',
+ 'map-icon-funeral-home': '\ue83c',
+ 'map-icon-mortuary': '\ue865',
+ 'map-icon-cross': '\ue84f',
+ 'map-icon-synagogue': '\ue8af',
+ 'map-icon-temple': '\ue8b0',
+ 'map-icon-shrine': '\ue885',
+ 'map-icon-pagoda': '\ue86b',
+ 'map-icon-minaret': '\ue85e',
+ 'map-icon-candle': '\ue81f',
+ 'map-icon-lantern': '\ue8b2',
+ 'map-icon-prayer': '\ue86f',
+ 'map-icon-meditation': '\ue85b',
+ 'map-icon-yoga': '\ue8b3',
+ 'map-icon-massage': '\ue860',
+ 'map-icon-acupuncture': '\ue800',
+ 'map-icon-aromatherapy': '\ue801',
+ 'map-icon-chiropractor': '\ue848',
+ 'map-icon-veterinarian': '\ue8b4',
+ 'map-icon-animal-shelter': '\ue84f',
+ 'map-icon-pet-grooming': '\ue86d',
+ 'map-icon-kennel': '\ue850',
+};
+
+/**
+ * Resolves the Unicode character for a POI marker icon.
+ *
+ * @param poiImage - The icon CSS class name (e.g. "map-icon-hospital")
+ * or a raw icon key (e.g. "hospital").
+ * @returns The Unicode character string for the icon, or the default
+ * map-pin character ("\ue85d") as fallback.
+ */
+export const getPoiMarkerIconChar = (poiImage?: string | null): string => {
+ const raw = (poiImage ?? '').trim();
+
+ if (!raw) return MAP_ICONS_UNICODE['map-icon-map-pin'];
+
+ // Direct lookup with full class name
+ if (MAP_ICONS_UNICODE[raw]) return MAP_ICONS_UNICODE[raw];
+
+ // Try with "map-icon-" prefix
+ const withPrefix = `map-icon-${raw.toLowerCase().replace(/^map-icon-/, '')}`;
+ if (MAP_ICONS_UNICODE[withPrefix]) return MAP_ICONS_UNICODE[withPrefix];
+
+ // Try stripping "map-icon-" prefix and re-adding
+ const stripped = raw.toLowerCase().replace(/^map-icon-/, '');
+ const rePrefixed = `map-icon-${stripped}`;
+ if (MAP_ICONS_UNICODE[rePrefixed]) return MAP_ICONS_UNICODE[rePrefixed];
+
+ return MAP_ICONS_UNICODE['map-icon-map-pin'];
+};
+
+// ---------------------------------------------------------------------------
+// Non-POI (legacy) marker icon resolution
+// ---------------------------------------------------------------------------
+
+const normalizeMarkerToken = (token?: string | null) =>
+ token
+ ?.trim()
+ .toLowerCase()
+ .replace(/[\s-]+/g, '') ?? '';
+
+export const resolveMapMarkerIconKey = (pin: Pick): MapIconKey => {
+ // Prefer PoiImage (new field) over ImagePath (null for POIs after backend fix)
+ const resolvedPath = pin.PoiImage || pin.ImagePath;
+
+ // For POI markers, resolveMapMarkerIconKey is not used for rendering
+ // (they use the SVG shape + map-icons font system). Return 'flag' as
+ // a sensible fallback only when the legacy PNG system is still applied.
+ if (pin.Type === MapMarkerEntityType.Poi) {
+ if (resolvedPath?.toLowerCase().startsWith('map-icon-')) {
+ return 'flag';
+ }
+ }
+
+ const normalizedToken = normalizeMarkerToken(resolvedPath);
+
+ if (normalizedToken && MAP_ICONS[normalizedToken]) {
+ return normalizedToken as MapIconKey;
+ }
+
+ switch (pin.Type) {
+ case MapMarkerEntityType.Unit:
+ return 'truck';
+ case MapMarkerEntityType.Station:
+ return 'station';
+ case MapMarkerEntityType.Personnel:
+ return 'person';
+ case MapMarkerEntityType.Poi:
+ return 'flag';
+ case MapMarkerEntityType.Call:
+ default:
+ return 'call';
+ }
+};
+
+// ---------------------------------------------------------------------------
+// Marker helper utilities
+// ---------------------------------------------------------------------------
+
+export const hasValidMapCoordinates = (pin: Pick) => {
+ return Number.isFinite(pin.Latitude) && Number.isFinite(pin.Longitude) && !(pin.Latitude === 0 && pin.Longitude === 0);
+};
+
+export const getMapMarkerColor = (pin: Pick) => {
+ if (pin.Color) {
+ return pin.Color;
+ }
+
+ switch (pin.Type) {
+ case MapMarkerEntityType.Poi:
+ return '#2563eb';
+ case MapMarkerEntityType.Station:
+ return '#2563eb';
+ case MapMarkerEntityType.Unit:
+ return '#16a34a';
+ case MapMarkerEntityType.Personnel:
+ return '#7c3aed';
+ case MapMarkerEntityType.Call:
+ default:
+ return '#f97316';
+ }
+};
+
+export const getMapPinSummary = (pin: Pick) => {
+ return pin.Address || pin.Note || pin.PoiTypeName || pin.InfoWindowContent || '';
+};
diff --git a/src/lib/poi-display.ts b/src/lib/poi-display.ts
new file mode 100644
index 0000000..f38f3cf
--- /dev/null
+++ b/src/lib/poi-display.ts
@@ -0,0 +1,37 @@
+import { type PoiResultData } from '@/models/v4/mapping/poiResultData';
+
+const getTrimmedValue = (value?: string | null) => value?.trim() ?? '';
+
+export const getPoiPrimaryDisplayText = (poi: Pick): string => {
+ return getTrimmedValue(poi.Name) || getTrimmedValue(poi.Address) || getTrimmedValue(poi.Note) || getTrimmedValue(poi.PoiTypeName);
+};
+
+export const getPoiSelectionLabel = (poi: Pick): string => {
+ const name = getTrimmedValue(poi.Name);
+ const address = getTrimmedValue(poi.Address);
+
+ if (name && address) {
+ return `${name} - ${address}`;
+ }
+
+ return getPoiPrimaryDisplayText(poi);
+};
+
+export const getPoiDestinationOptionLabel = (poi: Pick): string => {
+ const typeName = getTrimmedValue(poi.PoiTypeName);
+ const label = getPoiSelectionLabel(poi);
+
+ return typeName ? `${typeName}: ${label}` : label;
+};
+
+export const getPoiSecondaryDisplayText = (poi: Pick): string => {
+ return getTrimmedValue(poi.Address) || getTrimmedValue(poi.Note) || getTrimmedValue(poi.PoiTypeName);
+};
+
+export const getPoiSearchValue = (poi: Pick): string => {
+ return [poi.Name, poi.Address, poi.Note, poi.PoiTypeName]
+ .map((value) => getTrimmedValue(value))
+ .filter(Boolean)
+ .join(' ')
+ .toLowerCase();
+};
diff --git a/src/lib/poi-map-layers.ts b/src/lib/poi-map-layers.ts
new file mode 100644
index 0000000..2e6bbfc
--- /dev/null
+++ b/src/lib/poi-map-layers.ts
@@ -0,0 +1,20 @@
+import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
+import { type PoiLayerData } from '@/models/v4/mapping/poiLayerData';
+
+import { MapMarkerEntityType } from './destination-helpers';
+
+export const getPoiMapLayerId = (poiTypeId: number) => `poi-${poiTypeId}`;
+
+export const createDefaultVisiblePoiLayerIds = (poiLayers: PoiLayerData[]) => {
+ return new Set(poiLayers.map((poiLayer) => getPoiMapLayerId(poiLayer.PoiTypeId)));
+};
+
+export const filterMapPinsByPoiLayers = (pins: MapMakerInfoData[], visiblePoiLayerIds: Set) => {
+ return pins.filter((pin) => {
+ if (pin.Type !== MapMarkerEntityType.Poi || pin.PoiTypeId == null) {
+ return true;
+ }
+
+ return visiblePoiLayerIds.has(getPoiMapLayerId(pin.PoiTypeId));
+ });
+};
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index f90ed70..9e1ceea 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -4,6 +4,7 @@ import { Platform } from 'react-native';
import type { StoreApi, UseBoundStore } from 'zustand';
import { Env } from './env';
+import { getBaseApiUrl } from './storage/app';
/**
* Call State Constants
@@ -111,7 +112,7 @@ export function onSortOptions(a: any, b: any) {
}
export function getAvatarUrl(userId: string) {
- return Env.BASE_API_URL + Env.RESGRID_API_URL + '/Avatars/Get?id=' + userId;
+ return getBaseApiUrl() + '/Avatars/Get?id=' + userId;
}
export function invertColor(hex: string, bw: boolean): string {
@@ -151,6 +152,39 @@ export function padZero(str: string, len: number): string {
return (zeros + str).slice(-len);
}
+export function toRgbaWithAlpha(color: string, alpha: number): string {
+ const a = Number.isFinite(alpha) ? Math.min(1, Math.max(0, alpha)) : 1;
+ if (!color) return `rgba(0, 0, 0, ${a})`;
+
+ const clamp = (n: number) => (Number.isFinite(n) ? Math.min(255, Math.max(0, n)) : 0);
+ const trimmed = color.trim();
+
+ // hex: #RGB, #RRGGBB, #RRGGBBAA
+ if (trimmed.startsWith('#')) {
+ let hex = trimmed.slice(1);
+ if (hex.length === 3) {
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
+ }
+ if (hex.length >= 6) {
+ const r = clamp(parseInt(hex.slice(0, 2), 16));
+ const g = clamp(parseInt(hex.slice(2, 4), 16));
+ const b = clamp(parseInt(hex.slice(4, 6), 16));
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+ }
+ }
+
+ // rgb(r, g, b) or rgba(r, g, b, a)
+ const rgbMatch = trimmed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
+ if (rgbMatch) {
+ const r = clamp(Number(rgbMatch[1]));
+ const g = clamp(Number(rgbMatch[2]));
+ const b = clamp(Number(rgbMatch[3]));
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+ }
+
+ return trimmed;
+}
+
export function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = (Math.random() * 16) | 0,
@@ -197,7 +231,10 @@ export function getMinutesBetweenDates(startDate: Date, endDate: Date): number {
return diff / 60000;
}
-export function parseDateISOString(s: string): Date {
+export function parseDateISOString(s: string | undefined | null): Date | null {
+ if (!s) {
+ return null;
+ }
const b = s.split(/\D/);
// Ensure we have all required parts
const [year, month, day, hour = '0', minute = '0', second = '0'] = b;
@@ -218,7 +255,7 @@ export function getDate(date: string): string {
return datestring;
}
-export function formatDateForDisplay(date: Date, format: string): string {
+export function formatDateForDisplay(date: Date | null, format: string): string {
// Original idea from: https://weblog.west-wind.com/posts/2008/Mar/18/A-simple-formatDate-function-for-JavaScript
if (!date) {
diff --git a/src/models/v4/calls/callResultData.ts b/src/models/v4/calls/callResultData.ts
index 29adc31..51e1147 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 = null;
+ public DestinationName: string = '';
+ public DestinationAddress: string = '';
+ public DestinationTypeName: string = '';
+ public DestinationPoiTypeId: number | null = null;
+ public DestinationLatitude: number | null = null;
+ public DestinationLongitude: number | null = null;
public Geolocation: string = '';
public LoggedOn: string = '';
// State: 0 = Active, 1 = Open, 2 = Pending, 3 = Scheduled, 4 = Closed (can be number or string depending on API version)
@@ -25,7 +32,11 @@ export class CallResultData {
public LoggedOnUtc: string = '';
public DispatchedOn: string = '';
public DispatchedOnUtc: string = '';
+ public ScheduledOn: string = '';
+ public ScheduledOnUtc: string = '';
public Latitude: string = '';
public Longitude: string = '';
+ public Protocols: unknown[] = [];
+ public UdfValues: unknown[] = [];
public CheckInTimersEnabled: boolean = false;
}
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/mapping/getMapDataAndMarkersData.ts b/src/models/v4/mapping/getMapDataAndMarkersData.ts
index cf3ecb2..4824b85 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,17 @@ export class MapMakerInfoData {
public Longitude: number = 0;
public Latitude: number = 0;
public Title: string = '';
- public zIndex: string = '';
+ public zIndex: number = 0;
public ImagePath: string = '';
public InfoWindowContent: string = '';
public Color: string = '';
public Type: number = 0;
+ public PoiImage: string = '';
+ public Marker: string = '';
+ public PoiTypeId: number | null = 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..89f533e
--- /dev/null
+++ b/src/models/v4/mapping/poiLayerData.ts
@@ -0,0 +1,9 @@
+export class PoiLayerData {
+ public PoiTypeId: number = 0;
+ public Name: string = '';
+ public Color: string = '';
+ public ImagePath: string = '';
+ public PoiImage: 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..732191a 100644
--- a/src/models/v4/personnelStatuses/getCurrentStatusResultData.ts
+++ b/src/models/v4/personnelStatuses/getCurrentStatusResultData.ts
@@ -4,8 +4,11 @@ export class GetCurrentStatusResultData {
public StatusType: number = 0;
public TimestampUtc: string = '';
public Timestamp: string = '';
+ public DestinationName: string = '';
+ public DestinationAddress: string = '';
+ public DestinationTypeName: string = '';
public Note: string = '';
- public DestinationId: string = '';
- public DestinationType: string = '';
+ public DestinationId: number | null = null;
+ public DestinationType: number | null = null;
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/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..21d44bc 100644
--- a/src/models/v4/unitStatus/unitStatusResultData.ts
+++ b/src/models/v4/unitStatus/unitStatusResultData.ts
@@ -5,12 +5,17 @@ export class UnitStatusResultData {
public StateCss: string = '';
public StateStyle: string = '';
public Timestamp: string = '';
- public DestinationId: string = '';
+ public TimestampUtc: string = '';
+ public DestinationId: number | null = null;
+ public DestinationType: number | null = null;
+ public DestinationName: string = '';
+ public DestinationAddress: string = '';
+ public DestinationTypeName: string = '';
public Note: string = '';
- public Latitude: string = '';
- public Longitude: string = '';
+ public Latitude: number | null = null;
+ public Longitude: number | null = null;
public GroupName: string = '';
- public GroupId: string = '';
+ public GroupId: number = 0;
public Eta: string = '';
public UnitId: string = '';
}
diff --git a/src/stores/calls/scheduled-store.ts b/src/stores/calls/scheduled-store.ts
new file mode 100644
index 0000000..5cd8571
--- /dev/null
+++ b/src/stores/calls/scheduled-store.ts
@@ -0,0 +1,68 @@
+import { create } from 'zustand';
+
+import { getPendingScheduledCalls } from '@/api/calls/calls';
+import { logger } from '@/lib/logging';
+import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData';
+import { type CallResultData } from '@/models/v4/calls/callResultData';
+
+import { useCallsStore } from './store';
+
+interface ScheduledCallsState {
+ scheduledCalls: CallResultData[];
+ isLoading: boolean;
+ error: string | null;
+ lastFetched: number;
+
+ fetchScheduledCalls: () => Promise;
+ getPriorityForCall: (priorityId: number) => CallPriorityResultData | undefined;
+ reset: () => void;
+}
+
+const initialState = {
+ scheduledCalls: [],
+ isLoading: false,
+ error: null,
+ lastFetched: 0,
+};
+
+export const useScheduledCallsStore = create((set) => ({
+ ...initialState,
+
+ fetchScheduledCalls: async () => {
+ set({ isLoading: true, error: null });
+
+ try {
+ logger.info({ message: 'Fetching pending scheduled calls from API' });
+
+ const response = await getPendingScheduledCalls();
+ const callsData = response?.Data ?? [];
+
+ logger.info({
+ message: 'Pending scheduled calls fetched successfully',
+ context: { count: callsData.length },
+ });
+
+ set({
+ scheduledCalls: callsData,
+ isLoading: false,
+ lastFetched: Date.now(),
+ });
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Failed to fetch scheduled calls';
+ logger.error({
+ message: 'Failed to fetch pending scheduled calls',
+ context: { error },
+ });
+ set({
+ error: errorMessage,
+ isLoading: false,
+ });
+ }
+ },
+
+ getPriorityForCall: (priorityId: number) => {
+ return useCallsStore.getState().callPriorities.find((p) => p.Id === priorityId);
+ },
+
+ reset: () => set(initialState),
+}));
diff --git a/src/stores/checkIn/store.ts b/src/stores/checkIn/store.ts
index a208a39..83bbbe4 100644
--- a/src/stores/checkIn/store.ts
+++ b/src/stores/checkIn/store.ts
@@ -13,10 +13,7 @@ import { useUnitsStore } from '@/stores/units/store';
* 2. Units store (by every possible ID match)
* 3. Personnel store (by every possible ID match)
*/
-function enrichTimerNames(
- statuses: CheckInTimerStatusResultData[],
- resolved: ResolvedCheckInTimerResultData[]
-): CheckInTimerStatusResultData[] {
+function enrichTimerNames(statuses: CheckInTimerStatusResultData[], resolved: ResolvedCheckInTimerResultData[]): CheckInTimerStatusResultData[] {
// Build name lookup from resolved timers
const resolvedNameMap = new Map();
for (const r of resolved) {
@@ -57,9 +54,7 @@ function enrichTimerNames(
return statuses.map((s) => {
// Skip if TargetName is a real entity name (not a type label like "UnitType")
- const nameIsTypeLabel = !s.TargetName
- || /type$/i.test(s.TargetName)
- || s.TargetName === s.TargetTypeName;
+ const nameIsTypeLabel = !s.TargetName || /type$/i.test(s.TargetName) || s.TargetName === s.TargetTypeName;
if (s.TargetName && !nameIsTypeLabel) return s;
let name: string | undefined;
@@ -152,10 +147,7 @@ export const useCheckInStore = create((set, get) => ({
fetchTimerStatuses: async (callId: number) => {
set({ isLoadingStatuses: true, statusError: null });
try {
- const [statusResult, resolvedResult] = await Promise.all([
- getTimerStatuses(callId),
- getTimersForCall(callId).catch(() => ({ Data: [] as ResolvedCheckInTimerResultData[] })),
- ]);
+ const [statusResult, resolvedResult] = await Promise.all([getTimerStatuses(callId), getTimersForCall(callId).catch(() => ({ Data: [] as ResolvedCheckInTimerResultData[] }))]);
const enriched = enrichTimerNames(statusResult.Data || [], resolvedResult.Data || []);
const sorted = enriched.sort(sortByStatusSeverity);
set({ timerStatuses: sorted, resolvedTimers: resolvedResult.Data || [], isLoadingStatuses: false });
@@ -174,10 +166,7 @@ export const useCheckInStore = create((set, get) => ({
}
set({ isLoadingStatuses: true, statusError: null });
try {
- const [statusResult, ...resolvedResults] = await Promise.all([
- getTimerStatusesForCalls(callIds),
- ...callIds.map((id) => getTimersForCall(id).catch(() => ({ Data: [] as ResolvedCheckInTimerResultData[] }))),
- ]);
+ const [statusResult, ...resolvedResults] = await Promise.all([getTimerStatusesForCalls(callIds), ...callIds.map((id) => getTimersForCall(id).catch(() => ({ Data: [] as ResolvedCheckInTimerResultData[] })))]);
const allResolved = resolvedResults.flatMap((r) => r.Data || []);
const enriched = enrichTimerNames(statusResult.Data || [], allResolved);
const sorted = enriched.sort(sortByStatusSeverity);
diff --git a/src/stores/dispatch/personnel-actions-store.ts b/src/stores/dispatch/personnel-actions-store.ts
index 5bf3d09..f8e2c70 100644
--- a/src/stores/dispatch/personnel-actions-store.ts
+++ b/src/stores/dispatch/personnel-actions-store.ts
@@ -2,13 +2,15 @@ import { create } from 'zustand';
import { savePersonsStaffings } from '@/api/personnel/personnelStaffing';
import { savePersonsStatuses } from '@/api/personnel/personnelStatuses';
+import { DestinationEntityType, type DestinationSelectionType } from '@/lib/destination-helpers';
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 PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData';
import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData';
export type PersonnelActionTab = 'status' | 'staffing';
-export type DestinationType = 'none' | 'call' | 'station';
+export type DestinationType = DestinationSelectionType;
interface PersonnelActionsState {
// Panel visibility
@@ -25,6 +27,7 @@ interface PersonnelActionsState {
statusDestinationType: DestinationType;
statusSelectedCall: CallResultData | null;
statusSelectedStation: GroupResultData | null;
+ statusSelectedPoi: PoiResultData | null;
statusNote: string;
isSubmittingStatus: boolean;
@@ -38,6 +41,7 @@ interface PersonnelActionsState {
availableStaffings: StatusesResultData[];
availableCalls: CallResultData[];
availableStations: GroupResultData[];
+ availablePois: PoiResultData[];
isLoadingOptions: boolean;
// Error handling
@@ -54,6 +58,7 @@ interface PersonnelActionsState {
setStatusDestinationType: (type: DestinationType) => void;
setStatusSelectedCall: (call: CallResultData | null) => void;
setStatusSelectedStation: (station: GroupResultData | null) => void;
+ setStatusSelectedPoi: (poi: PoiResultData | null) => void;
setStatusNote: (note: string) => void;
submitStatus: (overrides?: { personnel?: PersonnelInfoResultData; status?: StatusesResultData }) => Promise;
resetStatusForm: () => void;
@@ -69,6 +74,7 @@ interface PersonnelActionsState {
setAvailableStaffings: (staffings: StatusesResultData[]) => void;
setAvailableCalls: (calls: CallResultData[]) => void;
setAvailableStations: (stations: GroupResultData[]) => void;
+ setAvailablePois: (pois: PoiResultData[]) => void;
setIsLoadingOptions: (loading: boolean) => void;
// Reset everything
@@ -83,6 +89,7 @@ const initialState = {
statusDestinationType: 'none' as DestinationType,
statusSelectedCall: null,
statusSelectedStation: null,
+ statusSelectedPoi: null,
statusNote: '',
isSubmittingStatus: false,
selectedStaffing: null,
@@ -92,6 +99,7 @@ const initialState = {
availableStaffings: [],
availableCalls: [],
availableStations: [],
+ availablePois: [],
isLoadingOptions: false,
statusError: null,
staffingError: null,
@@ -110,6 +118,7 @@ export const usePersonnelActionsStore = create((set, get)
statusDestinationType: 'none',
statusSelectedCall: null,
statusSelectedStation: null,
+ statusSelectedPoi: null,
statusNote: '',
selectedStaffing: null,
staffingNote: '',
@@ -136,10 +145,16 @@ export const usePersonnelActionsStore = create((set, get)
if (type === 'none') {
updates.statusSelectedCall = null;
updates.statusSelectedStation = null;
+ updates.statusSelectedPoi = null;
} else if (type === 'call') {
updates.statusSelectedStation = null;
+ updates.statusSelectedPoi = null;
} else if (type === 'station') {
updates.statusSelectedCall = null;
+ updates.statusSelectedPoi = null;
+ } else if (type === 'poi') {
+ updates.statusSelectedCall = null;
+ updates.statusSelectedStation = null;
}
set(updates);
},
@@ -149,6 +164,7 @@ export const usePersonnelActionsStore = create((set, get)
statusSelectedCall: call,
statusDestinationType: 'call',
statusSelectedStation: null,
+ statusSelectedPoi: null,
}),
setStatusSelectedStation: (station) =>
@@ -156,6 +172,15 @@ export const usePersonnelActionsStore = create((set, get)
statusSelectedStation: station,
statusDestinationType: 'station',
statusSelectedCall: null,
+ statusSelectedPoi: null,
+ }),
+
+ setStatusSelectedPoi: (poi) =>
+ set({
+ statusSelectedPoi: poi,
+ statusDestinationType: 'poi',
+ statusSelectedCall: null,
+ statusSelectedStation: null,
}),
setStatusNote: (note) => set({ statusNote: note }),
@@ -164,7 +189,7 @@ export const usePersonnelActionsStore = create((set, get)
const storeState = get();
const selectedPersonnel = overrides?.personnel ?? storeState.selectedPersonnel;
const selectedStatus = overrides?.status ?? storeState.selectedStatus;
- const { statusDestinationType, statusSelectedCall, statusSelectedStation, statusNote } = storeState;
+ const { statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, statusNote } = storeState;
if (!selectedPersonnel || !selectedStatus) {
set({ statusError: 'Please select a status' });
@@ -181,12 +206,25 @@ export const usePersonnelActionsStore = create((set, get)
respondingTo = statusSelectedCall.CallId;
} else if (statusDestinationType === 'station' && statusSelectedStation) {
respondingTo = statusSelectedStation.GroupId;
+ } else if (statusDestinationType === 'poi' && statusSelectedPoi) {
+ respondingTo = statusSelectedPoi.PoiId.toString();
+ }
+
+ let respondingToType: number | null = null;
+
+ if (statusDestinationType === 'call' && statusSelectedCall) {
+ respondingToType = DestinationEntityType.Call;
+ } else if (statusDestinationType === 'station' && statusSelectedStation) {
+ respondingToType = DestinationEntityType.Station;
+ } else if (statusDestinationType === 'poi' && statusSelectedPoi) {
+ respondingToType = DestinationEntityType.Poi;
}
await savePersonsStatuses({
UserIds: [selectedPersonnel.UserId],
Type: selectedStatus.Id.toString(),
RespondingTo: respondingTo,
+ RespondingToType: respondingToType,
TimestampUtc: date.toUTCString().replace('UTC', 'GMT'),
Timestamp: date.toISOString(),
Note: statusNote,
@@ -207,6 +245,7 @@ export const usePersonnelActionsStore = create((set, get)
statusDestinationType: 'none',
statusSelectedCall: null,
statusSelectedStation: null,
+ statusSelectedPoi: null,
statusNote: '',
});
@@ -226,6 +265,7 @@ export const usePersonnelActionsStore = create((set, get)
statusDestinationType: 'none',
statusSelectedCall: null,
statusSelectedStation: null,
+ statusSelectedPoi: null,
statusNote: '',
statusError: null,
}),
@@ -289,6 +329,7 @@ export const usePersonnelActionsStore = create((set, get)
setAvailableStaffings: (staffings) => set({ availableStaffings: staffings }),
setAvailableCalls: (calls) => set({ availableCalls: calls }),
setAvailableStations: (stations) => set({ availableStations: stations }),
+ setAvailablePois: (pois) => set({ availablePois: pois }),
setIsLoadingOptions: (loading) => set({ isLoadingOptions: loading }),
// Reset everything
diff --git a/src/stores/dispatch/unit-actions-store.ts b/src/stores/dispatch/unit-actions-store.ts
index d042afa..f78fb2e 100644
--- a/src/stores/dispatch/unit-actions-store.ts
+++ b/src/stores/dispatch/unit-actions-store.ts
@@ -1,13 +1,15 @@
import { create } from 'zustand';
import { saveUnitStatus } from '@/api/units/unitStatuses';
+import { DestinationEntityType, type DestinationSelectionType } from '@/lib/destination-helpers';
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 StatusesResultData } from '@/models/v4/statuses/statusesResultData';
import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData';
import { SaveUnitStatusInput } from '@/models/v4/unitStatus/saveUnitStatusInput';
-export type DestinationType = 'none' | 'call' | 'station';
+export type DestinationType = DestinationSelectionType;
interface UnitActionsState {
// Panel visibility
@@ -21,6 +23,7 @@ interface UnitActionsState {
statusDestinationType: DestinationType;
statusSelectedCall: CallResultData | null;
statusSelectedStation: GroupResultData | null;
+ statusSelectedPoi: PoiResultData | null;
statusNote: string;
isSubmittingStatus: boolean;
@@ -28,6 +31,7 @@ interface UnitActionsState {
availableStatuses: StatusesResultData[];
availableCalls: CallResultData[];
availableStations: GroupResultData[];
+ availablePois: PoiResultData[];
isLoadingOptions: boolean;
// Error handling
@@ -42,6 +46,7 @@ interface UnitActionsState {
setStatusDestinationType: (type: DestinationType) => void;
setStatusSelectedCall: (call: CallResultData | null) => void;
setStatusSelectedStation: (station: GroupResultData | null) => void;
+ setStatusSelectedPoi: (poi: PoiResultData | null) => void;
setStatusNote: (note: string) => void;
submitStatus: (overrides?: { unit?: UnitInfoResultData; status?: StatusesResultData }) => Promise;
resetStatusForm: () => void;
@@ -50,6 +55,7 @@ interface UnitActionsState {
setAvailableStatuses: (statuses: StatusesResultData[]) => void;
setAvailableCalls: (calls: CallResultData[]) => void;
setAvailableStations: (stations: GroupResultData[]) => void;
+ setAvailablePois: (pois: PoiResultData[]) => void;
setIsLoadingOptions: (loading: boolean) => void;
// Reset everything
@@ -63,11 +69,13 @@ const initialState = {
statusDestinationType: 'none' as DestinationType,
statusSelectedCall: null,
statusSelectedStation: null,
+ statusSelectedPoi: null,
statusNote: '',
isSubmittingStatus: false,
availableStatuses: [],
availableCalls: [],
availableStations: [],
+ availablePois: [],
isLoadingOptions: false,
statusError: null,
};
@@ -84,6 +92,7 @@ export const useUnitActionsStore = create((set, get) => ({
statusDestinationType: 'none',
statusSelectedCall: null,
statusSelectedStation: null,
+ statusSelectedPoi: null,
statusNote: '',
statusError: null,
});
@@ -105,10 +114,16 @@ export const useUnitActionsStore = create((set, get) => ({
if (type === 'none') {
updates.statusSelectedCall = null;
updates.statusSelectedStation = null;
+ updates.statusSelectedPoi = null;
} else if (type === 'call') {
updates.statusSelectedStation = null;
+ updates.statusSelectedPoi = null;
} else if (type === 'station') {
updates.statusSelectedCall = null;
+ updates.statusSelectedPoi = null;
+ } else if (type === 'poi') {
+ updates.statusSelectedCall = null;
+ updates.statusSelectedStation = null;
}
set(updates);
},
@@ -118,6 +133,7 @@ export const useUnitActionsStore = create((set, get) => ({
statusSelectedCall: call,
statusDestinationType: 'call',
statusSelectedStation: null,
+ statusSelectedPoi: null,
}),
setStatusSelectedStation: (station) =>
@@ -125,6 +141,15 @@ export const useUnitActionsStore = create((set, get) => ({
statusSelectedStation: station,
statusDestinationType: 'station',
statusSelectedCall: null,
+ statusSelectedPoi: null,
+ }),
+
+ setStatusSelectedPoi: (poi) =>
+ set({
+ statusSelectedPoi: poi,
+ statusDestinationType: 'poi',
+ statusSelectedCall: null,
+ statusSelectedStation: null,
}),
setStatusNote: (note) => set({ statusNote: note }),
@@ -133,7 +158,7 @@ export const useUnitActionsStore = create((set, get) => ({
const storeState = get();
const selectedUnit = overrides?.unit ?? storeState.selectedUnit;
const selectedStatus = overrides?.status ?? storeState.selectedStatus;
- const { statusDestinationType, statusSelectedCall, statusSelectedStation, statusNote } = storeState;
+ const { statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, statusNote } = storeState;
if (!selectedUnit || !selectedStatus) {
set({ statusError: 'Please select a status' });
@@ -145,17 +170,24 @@ export const useUnitActionsStore = create((set, get) => ({
try {
const date = new Date();
let respondingTo = '';
+ let respondingToType: number | null = null;
if (statusDestinationType === 'call' && statusSelectedCall) {
respondingTo = statusSelectedCall.CallId;
+ respondingToType = DestinationEntityType.Call;
} else if (statusDestinationType === 'station' && statusSelectedStation) {
respondingTo = statusSelectedStation.GroupId;
+ respondingToType = DestinationEntityType.Station;
+ } else if (statusDestinationType === 'poi' && statusSelectedPoi) {
+ respondingTo = statusSelectedPoi.PoiId.toString();
+ respondingToType = DestinationEntityType.Poi;
}
const input = new SaveUnitStatusInput();
input.Id = selectedUnit.UnitId;
input.Type = selectedStatus.Id.toString();
input.RespondingTo = respondingTo;
+ input.RespondingToType = respondingToType;
input.TimestampUtc = date.toUTCString().replace('UTC', 'GMT');
input.Timestamp = date.toISOString();
input.Note = statusNote;
@@ -179,6 +211,7 @@ export const useUnitActionsStore = create((set, get) => ({
statusDestinationType: 'none',
statusSelectedCall: null,
statusSelectedStation: null,
+ statusSelectedPoi: null,
statusNote: '',
});
@@ -198,6 +231,7 @@ export const useUnitActionsStore = create((set, get) => ({
statusDestinationType: 'none',
statusSelectedCall: null,
statusSelectedStation: null,
+ statusSelectedPoi: null,
statusNote: '',
statusError: null,
}),
@@ -206,6 +240,7 @@ export const useUnitActionsStore = create((set, get) => ({
setAvailableStatuses: (statuses) => set({ availableStatuses: statuses }),
setAvailableCalls: (calls) => set({ availableCalls: calls }),
setAvailableStations: (stations) => set({ availableStations: stations }),
+ setAvailablePois: (pois) => set({ availablePois: pois }),
setIsLoadingOptions: (loading) => set({ isLoadingOptions: loading }),
// Reset everything
diff --git a/src/stores/pois/store.ts b/src/stores/pois/store.ts
new file mode 100644
index 0000000..9ee3f64
--- /dev/null
+++ b/src/stores/pois/store.ts
@@ -0,0 +1,165 @@
+import { create } from 'zustand';
+
+import { getPoi, getPois, getPoiTypes } from '@/api/mapping/mapping';
+import { logger } from '@/lib/logging';
+import { type PoiResultData } from '@/models/v4/mapping/poiResultData';
+import { type PoiTypeResultData } from '@/models/v4/mapping/poiTypeResultData';
+
+interface PoisState {
+ pois: PoiResultData[];
+ destinationPois: PoiResultData[];
+ poiTypes: PoiTypeResultData[];
+ selectedPoi: PoiResultData | null;
+ isLoading: boolean;
+ isLoadingDestinationPois: boolean;
+ isLoadingTypes: boolean;
+ isLoadingDetail: boolean;
+ error: string | null;
+ detailError: string | null;
+ fetchPois: (forceRefresh?: boolean) => Promise;
+ fetchDestinationPois: (forceRefresh?: boolean) => Promise;
+ fetchPoiTypes: (forceRefresh?: boolean) => Promise;
+ fetchPoi: (poiId: number, forceRefresh?: boolean) => Promise;
+ getPoiById: (poiId: number) => PoiResultData | undefined;
+ resetSelectedPoi: () => void;
+}
+
+export const usePoisStore = create((set, get) => ({
+ pois: [],
+ destinationPois: [],
+ poiTypes: [],
+ selectedPoi: null,
+ isLoading: false,
+ isLoadingDestinationPois: false,
+ isLoadingTypes: false,
+ isLoadingDetail: false,
+ error: null,
+ detailError: null,
+
+ fetchPois: async (forceRefresh = false) => {
+ if (!forceRefresh && get().pois.length > 0) {
+ return;
+ }
+
+ set({ isLoading: true, error: null });
+
+ try {
+ const response = await getPois();
+ set({ pois: response?.Data ?? [], isLoading: false });
+ } catch (error) {
+ logger.error({
+ message: 'Failed to fetch POIs',
+ context: { error },
+ });
+ set({
+ isLoading: false,
+ error: error instanceof Error ? error.message : 'Failed to fetch POIs',
+ });
+ }
+ },
+
+ fetchDestinationPois: async (forceRefresh = false) => {
+ if (!forceRefresh && get().destinationPois.length > 0) {
+ return;
+ }
+
+ set({ isLoadingDestinationPois: true, error: null });
+
+ try {
+ const response = await getPois({ destinationOnly: true });
+ set({ destinationPois: response?.Data ?? [], isLoadingDestinationPois: false });
+ } catch (error) {
+ logger.error({
+ message: 'Failed to fetch destination POIs',
+ context: { error },
+ });
+ set({
+ isLoadingDestinationPois: false,
+ error: error instanceof Error ? error.message : 'Failed to fetch destination POIs',
+ });
+ }
+ },
+
+ fetchPoiTypes: async (forceRefresh = false) => {
+ if (!forceRefresh && get().poiTypes.length > 0) {
+ return;
+ }
+
+ set({ isLoadingTypes: true, error: null });
+
+ try {
+ const response = await getPoiTypes();
+ set({ poiTypes: response?.Data ?? [], isLoadingTypes: false });
+ } catch (error) {
+ logger.error({
+ message: 'Failed to fetch POI types',
+ context: { error },
+ });
+ set({
+ isLoadingTypes: false,
+ error: error instanceof Error ? error.message : 'Failed to fetch POI types',
+ });
+ }
+ },
+
+ fetchPoi: async (poiId: number, forceRefresh = false) => {
+ const cachedPoi = get().getPoiById(poiId);
+
+ if (cachedPoi) {
+ set({ selectedPoi: cachedPoi });
+ }
+
+ if (!forceRefresh && get().selectedPoi?.PoiId === poiId && cachedPoi) {
+ return;
+ }
+
+ set({ isLoadingDetail: true, detailError: null });
+
+ try {
+ const response = await getPoi(poiId);
+ const nextPoi = response?.Data ?? null;
+ const currentPois = get().pois;
+ const currentDestinationPois = get().destinationPois;
+
+ set({
+ selectedPoi: nextPoi,
+ pois: nextPoi ? upsertPoi(currentPois, nextPoi) : currentPois,
+ destinationPois: nextPoi?.IsDestination ? upsertPoi(currentDestinationPois, nextPoi) : nextPoi ? currentDestinationPois.filter((p) => p.PoiId !== nextPoi.PoiId) : currentDestinationPois,
+ isLoadingDetail: false,
+ });
+ } catch (error) {
+ logger.error({
+ message: 'Failed to fetch POI detail',
+ context: { error, poiId },
+ });
+ set({
+ isLoadingDetail: false,
+ detailError: error instanceof Error ? error.message : 'Failed to fetch POI details',
+ });
+ }
+ },
+
+ getPoiById: (poiId: number) => {
+ const { pois, destinationPois, selectedPoi } = get();
+
+ if (selectedPoi?.PoiId === poiId) {
+ return selectedPoi;
+ }
+
+ return pois.find((poi) => poi.PoiId === poiId) ?? destinationPois.find((poi) => poi.PoiId === poiId);
+ },
+
+ resetSelectedPoi: () => set({ selectedPoi: null, detailError: null, isLoadingDetail: false }),
+}));
+
+const upsertPoi = (pois: PoiResultData[], poi: PoiResultData) => {
+ const index = pois.findIndex((currentPoi) => currentPoi.PoiId === poi.PoiId);
+
+ if (index === -1) {
+ return [...pois, poi];
+ }
+
+ const nextPois = [...pois];
+ nextPois[index] = poi;
+ return nextPois;
+};
diff --git a/src/stores/status/store.ts b/src/stores/status/store.ts
index 31ed833..ab32cef 100644
--- a/src/stores/status/store.ts
+++ b/src/stores/status/store.ts
@@ -1,12 +1,15 @@
import { create } from 'zustand';
import { getCalls } from '@/api/calls/calls';
+import { getSetUnitStatusData } from '@/api/dispatch/dispatch';
import { getAllGroups } from '@/api/groups/groups';
import { saveUnitStatus } from '@/api/units/unitStatuses';
+import { type DestinationSelectionType } from '@/lib/destination-helpers';
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 StatusesResultData } from '@/models/v4/statuses/statusesResultData';
import { type SaveUnitStatusInput, type SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput';
@@ -15,7 +18,7 @@ import { useLocationStore } from '../app/location-store';
import { useRolesStore } from '../roles/store';
type StatusStep = 'select-status' | 'select-destination' | 'add-note';
-type DestinationType = 'none' | 'call' | 'station';
+type DestinationType = DestinationSelectionType;
// Status type that can accept both custom statuses and regular statuses
type StatusType = CustomStatusResultData | StatusesResultData;
@@ -25,18 +28,21 @@ 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[];
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;
@@ -49,12 +55,14 @@ 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: [],
isLoading: false,
error: null,
setIsOpen: (isOpen, status) => {
@@ -69,25 +77,47 @@ export const useStatusBottomSheetStore = create((set, ge
setCurrentStep: (step) => set({ currentStep: step }),
setSelectedCall: (call) => set({ selectedCall: call }),
setSelectedStation: (station) => set({ selectedStation: station }),
+ setSelectedPoi: (poi) => set({ selectedPoi: poi }),
setSelectedDestinationType: (type) => set({ 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 [callsResponse, groupsResponse] = await Promise.all([getCalls(), getAllGroups()]);
+ const response = await getSetUnitStatusData(unitId);
+ const destinationData = response?.Data;
set({
- availableCalls: callsResponse.Data || [],
- availableStations: groupsResponse.Data || [],
+ availableCalls: destinationData?.Calls || [],
+ availableStations: destinationData?.Stations || [],
+ availablePois: destinationData?.DestinationPois || [],
isLoading: false,
});
- } catch (error) {
- set({
- error: 'Failed to fetch destination data',
- isLoading: false,
+ } catch (primaryError) {
+ logger.error({
+ message: 'Failed to fetch destination data via getSetUnitStatusData, falling back',
+ context: { unitId, error: primaryError },
});
+ try {
+ const [callsResponse, groupsResponse] = await Promise.all([getCalls(), getAllGroups()]);
+
+ set({
+ availableCalls: callsResponse.Data || [],
+ availableStations: groupsResponse.Data || [],
+ availablePois: [],
+ isLoading: false,
+ });
+ } catch (fallbackError) {
+ logger.error({
+ message: 'Failed to fetch destination data via fallback',
+ context: { unitId, primaryError, fallbackError },
+ });
+ const errorMessage = fallbackError instanceof Error ? fallbackError.message : 'Failed to fetch destination data';
+ set({
+ error: errorMessage,
+ isLoading: false,
+ });
+ }
}
},
reset: () =>
@@ -96,12 +126,14 @@ 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: [],
isLoading: false,
error: null,
}),
diff --git a/src/translations/ar.json b/src/translations/ar.json
index eed32b9..4baf1a0 100644
--- a/src/translations/ar.json
+++ b/src/translations/ar.json
@@ -127,6 +127,9 @@
"contact_info": "معلومات الاتصال",
"contact_name": "اسم جهة الاتصال",
"contact_phone": "الهاتف",
+ "destination": "الوجهة",
+ "destination_address": "عنوان الوجهة",
+ "destination_type": "نوع الوجهة",
"edit_call": "تعديل المكالمة",
"external_id": "المعرف الخارجي",
"failed_to_open_maps": "فشل في فتح تطبيق الخرائط",
@@ -181,6 +184,7 @@
"video": "فيديو"
},
"timestamp": "الطابع الزمني",
+ "scheduled_on": "تاريخ ووقت الإرسال المجدول",
"title": "تفاصيل المكالمة",
"type": "النوع",
"unit": "الوحدة",
@@ -220,6 +224,8 @@
"description": "الوصف",
"description_placeholder": "أدخل وصف المكالمة",
"deselect": "إلغاء التحديد",
+ "destination": "الوجهة",
+ "destination_poi": "نقطة اهتمام الوجهة",
"directions": "الاتجاهات",
"dispatch_to": "إرسال إلى",
"dispatch_to_everyone": "إرسال إلى جميع الموظفين المتاحين",
@@ -237,6 +243,7 @@
"invalid_type": "نوع غير صحيح محدد. يرجى اختيار نوع مكالمة صحيح.",
"loading": "جاري تحميل المكالمات...",
"loading_calls": "جاري تحميل المكالمات...",
+ "loading_destination_pois": "جارٍ تحميل نقاط اهتمام الوجهة...",
"name": "الاسم",
"name_placeholder": "أدخل اسم المكالمة",
"nature": "الطبيعة",
@@ -248,6 +255,8 @@
"no_calls": "لا توجد مكالمات نشطة",
"no_calls_available": "لا توجد مكالمات متاحة",
"no_calls_description": "لم يتم العثور على مكالمات نشطة. اختر مكالمة نشطة لعرض التفاصيل.",
+ "no_destination": "بدون وجهة",
+ "no_destination_pois_available": "لا توجد نقاط اهتمام وجهة متاحة",
"no_location_message": "هذه المكالمة لا تحتوي على بيانات موقع متاحة للملاحة.",
"no_location_title": "الموقع غير متاح",
"no_open_calls": "لا توجد مكالمات مفتوحة متاحة",
@@ -268,6 +277,7 @@
"select_address_placeholder": "اختر عنوان المكالمة",
"select_description": "اختر الوصف",
"select_dispatch_recipients": "اختيار مستقبلي الإرسال",
+ "select_destination_poi": "اختر نقطة اهتمام الوجهة",
"select_location": "اختر الموقع على الخريطة",
"select_name": "اختر الاسم",
"select_nature": "اختر الطبيعة",
@@ -570,6 +580,7 @@
"menu": {
"calls": "المكالمات",
"calls_list": "قائمة المكالمات",
+ "scheduled_calls": "المكالمات المجدولة",
"contacts": "جهات الاتصال",
"home": "الرئيسية",
"map": "الخريطة",
@@ -577,11 +588,62 @@
"messages": "الرسائل",
"new_call": "مكالمة جديدة",
"personnel": "الموظفون",
+ "pois": "نقاط الاهتمام",
"protocols": "البروتوكولات",
"settings": "الإعدادات",
"units": "الوحدات",
"weatherAlerts": "تنبيهات الطقس"
},
+ "pois": {
+ "address": "العنوان",
+ "all_types": "كل الأنواع",
+ "destination": "وجهة",
+ "details": "التفاصيل",
+ "detail_not_found": "تعذر العثور على نقطة الاهتمام",
+ "detail_not_found_description": "تعذر تحميل نقطة الاهتمام المطلوبة.",
+ "detail_title": "تفاصيل نقطة الاهتمام",
+ "empty": "لا توجد نقاط اهتمام",
+ "empty_description": "لا توجد نقاط اهتمام متاحة لقسمك حتى الآن.",
+ "empty_filtered": "لا توجد نقاط اهتمام مطابقة",
+ "empty_filtered_description": "حاول مسح البحث أو اختيار نوع مختلف.",
+ "filter_by_type": "تصفية حسب نوع نقطة الاهتمام",
+ "invalid_poi": "نقطة اهتمام غير صالحة",
+ "invalid_poi_description": "معرّف نقطة الاهتمام المحدد غير صالح.",
+ "loading": "جارٍ تحميل نقاط الاهتمام...",
+ "loading_detail": "جارٍ تحميل تفاصيل نقطة الاهتمام...",
+ "map": "الخريطة",
+ "no_location": "لا يوجد موقع متاح",
+ "no_location_description": "لا تحتوي نقطة الاهتمام هذه على إحداثيات قابلة للاستخدام.",
+ "no_location_for_routing": "لا توجد بيانات موقع متاحة للتوجيه",
+ "note": "ملاحظة",
+ "route_error": "فشل في فتح تطبيق الخرائط",
+ "search": "ابحث في نقاط الاهتمام...",
+ "sort": "ترتيب",
+ "sort_options": {
+ "address-asc": "العنوان",
+ "name-asc": "الاسم (أ-ي)",
+ "name-desc": "الاسم (ي-أ)",
+ "type-asc": "النوع"
+ },
+ "title": "نقاط الاهتمام",
+ "type": "النوع",
+ "unknown_type": "نوع غير معروف",
+ "unnamed": "نقطة اهتمام بدون اسم"
+ },
+ "scheduled_calls": {
+ "title": "المكالمات المجدولة",
+ "loading": "تحميل المكالمات المجدولة...",
+ "no_scheduled_calls": "لا توجد مكالمات مجدولة",
+ "no_scheduled_calls_description": "لا توجد مكالمات مجدولة معلقة في الوقت الحالي.",
+ "search": "البحث في المكالمات المجدولة...",
+ "scheduled_for": "مجدولة ل",
+ "table_number": "Call #",
+ "table_name": "Name",
+ "table_type": "Type",
+ "table_priority": "Priority",
+ "table_address": "Address",
+ "table_scheduled": "Scheduled For"
+ },
"map": {
"call_set_as_current": "تم تعيين المكالمة كمكالمة حالية",
"failed_to_open_maps": "فشل في فتح تطبيق الخرائط",
@@ -595,6 +657,8 @@
"layers": "طبقات الخريطة",
"no_layers": "لا توجد طبقات متاحة",
"show_all": "إظهار الكل",
+ "view_poi_details": "عرض تفاصيل نقطة الاهتمام",
+ "dispatched_resources": "المرسلون",
"hide_all": "إخفاء الكل"
},
"notes": {
@@ -789,18 +853,24 @@
},
"status": {
"add_note": "إضافة ملاحظة",
+ "all_destinations_enabled": "يمكن الاستجابة للمكالمات أو المحطات أو نقاط الاهتمام",
"both_destinations_enabled": "يمكن الاستجابة للمكالمات أو المحطات",
"call_destination_enabled": "يمكن الاستجابة للمكالمات",
+ "calls_and_pois_destination_enabled": "يمكن الاستجابة للمكالمات أو نقاط الاهتمام",
"calls_tab": "المكالمات",
"failed_to_save_status": "فشل في حفظ الحالة. يرجى المحاولة مرة أخرى.",
"general_status": "حالة عامة بدون وجهة محددة",
+ "loading_pois": "جارٍ تحميل نقاط الاهتمام...",
"loading_stations": "جاري تحميل المحطات...",
"no_destination": "بدون وجهة",
+ "no_pois_available": "لا توجد نقاط اهتمام متاحة",
"no_stations_available": "لا توجد محطات متاحة",
"no_statuses_available": "لا توجد حالات متاحة",
"note": "ملاحظة",
"note_optional": "أضف ملاحظة اختيارية لتحديث الحالة هذا",
"note_required": "يرجى إدخال ملاحظة لتحديث الحالة هذا",
+ "poi_destination_enabled": "يمكن الاستجابة إلى نقاط الاهتمام",
+ "pois_tab": "نقاط الاهتمام",
"select_destination": "اختر الوجهة لـ {{status}}",
"select_destination_type": "أين تريد الاستجابة؟",
"select_status": "اختر الحالة",
@@ -809,6 +879,7 @@
"selected_status": "الحالة المختارة",
"set_status": "تعيين الحالة",
"station_destination_enabled": "يمكن الاستجابة للمحطات",
+ "stations_and_pois_destination_enabled": "يمكن الاستجابة للمحطات أو نقاط الاهتمام",
"stations_tab": "المحطات",
"status_saved_successfully": "تم حفظ الحالة بنجاح!"
},
diff --git a/src/translations/de.json b/src/translations/de.json
index 8df2d49..07fd84b 100644
--- a/src/translations/de.json
+++ b/src/translations/de.json
@@ -582,6 +582,20 @@
"units": "Einheiten",
"weatherAlerts": "Wetterwarnungen"
},
+ "scheduled_calls": {
+ "title": "Scheduled Calls",
+ "loading": "Loading scheduled calls...",
+ "no_scheduled_calls": "No scheduled calls",
+ "no_scheduled_calls_description": "There are no pending scheduled calls at this time.",
+ "search": "Search scheduled calls...",
+ "scheduled_for": "Scheduled for",
+ "table_number": "Call #",
+ "table_name": "Name",
+ "table_type": "Type",
+ "table_priority": "Priority",
+ "table_address": "Address",
+ "table_scheduled": "Scheduled For"
+ },
"map": {
"call_set_as_current": "Einsatz als aktuellen Einsatz gesetzt",
"failed_to_open_maps": "Kartenanwendung konnte nicht geöffnet werden",
diff --git a/src/translations/en.json b/src/translations/en.json
index a8f138c..2dcf692 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -127,6 +127,9 @@
"contact_info": "Contact Info",
"contact_name": "Contact Name",
"contact_phone": "Phone",
+ "destination": "Destination",
+ "destination_address": "Destination Address",
+ "destination_type": "Destination Type",
"edit_call": "Edit Call",
"external_id": "External ID",
"failed_to_open_maps": "Failed to open maps application",
@@ -181,6 +184,7 @@
"video": "Video"
},
"timestamp": "Timestamp",
+ "scheduled_on": "Scheduled Dispatch Date & Time",
"title": "Call Details",
"type": "Type",
"unit": "Unit",
@@ -220,6 +224,8 @@
"description": "Description",
"description_placeholder": "Enter the description of the call",
"deselect": "Deselect",
+ "destination": "Destination",
+ "destination_poi": "Destination POI",
"directions": "Directions",
"dispatch_to": "Dispatch To",
"dispatch_to_everyone": "Dispatch to all available personnel",
@@ -237,6 +243,7 @@
"invalid_type": "Invalid type selected. Please select a valid call type.",
"loading": "Loading calls...",
"loading_calls": "Loading calls...",
+ "loading_destination_pois": "Loading destination POIs...",
"name": "Name",
"name_placeholder": "Enter the name of the call",
"nature": "Nature",
@@ -248,6 +255,8 @@
"no_calls": "No active calls",
"no_calls_available": "No calls available",
"no_calls_description": "No active calls found. Select an active call to view details.",
+ "no_destination": "No destination",
+ "no_destination_pois_available": "No destination POIs available",
"no_location_message": "This call does not have location data available for navigation.",
"no_location_title": "No Location Available",
"no_open_calls": "No open calls available",
@@ -268,6 +277,7 @@
"select_address_placeholder": "Select the address of the call",
"select_description": "Select Description",
"select_dispatch_recipients": "Select Dispatch Recipients",
+ "select_destination_poi": "Select destination POI",
"select_location": "Select Location on Map",
"select_name": "Select Name",
"select_nature": "Select Nature",
@@ -570,6 +580,7 @@
"menu": {
"calls": "Calls",
"calls_list": "Calls List",
+ "scheduled_calls": "Scheduled Calls",
"contacts": "Contacts",
"home": "Home",
"map": "Map",
@@ -577,11 +588,62 @@
"messages": "Messages",
"new_call": "New Call",
"personnel": "Personnel",
+ "pois": "POIs",
"protocols": "Protocols",
"settings": "Settings",
"units": "Units",
"weatherAlerts": "Weather Alerts"
},
+ "pois": {
+ "address": "Address",
+ "all_types": "All types",
+ "destination": "Destination",
+ "details": "Details",
+ "detail_not_found": "POI not found",
+ "detail_not_found_description": "The requested POI could not be loaded.",
+ "detail_title": "POI Details",
+ "empty": "No POIs found",
+ "empty_description": "There are no points of interest available for your department yet.",
+ "empty_filtered": "No matching POIs",
+ "empty_filtered_description": "Try clearing your search or choosing a different POI type.",
+ "filter_by_type": "Filter by POI type",
+ "invalid_poi": "Invalid POI",
+ "invalid_poi_description": "The selected POI identifier is not valid.",
+ "loading": "Loading POIs...",
+ "loading_detail": "Loading POI details...",
+ "map": "Map",
+ "no_location": "No location available",
+ "no_location_description": "This POI does not have usable coordinates.",
+ "no_location_for_routing": "No location data available for routing",
+ "note": "Note",
+ "route_error": "Failed to open maps application",
+ "search": "Search POIs...",
+ "sort": "Sort",
+ "sort_options": {
+ "address-asc": "Address",
+ "name-asc": "Name (A-Z)",
+ "name-desc": "Name (Z-A)",
+ "type-asc": "Type"
+ },
+ "title": "POIs",
+ "type": "Type",
+ "unknown_type": "Unknown type",
+ "unnamed": "Unnamed POI"
+ },
+ "scheduled_calls": {
+ "title": "Scheduled Calls",
+ "loading": "Loading scheduled calls...",
+ "no_scheduled_calls": "No scheduled calls",
+ "no_scheduled_calls_description": "There are no pending scheduled calls at this time.",
+ "search": "Search scheduled calls...",
+ "scheduled_for": "Scheduled for",
+ "table_number": "Call #",
+ "table_name": "Name",
+ "table_type": "Type",
+ "table_priority": "Priority",
+ "table_address": "Address",
+ "table_scheduled": "Scheduled For"
+ },
"map": {
"call_set_as_current": "Call set as current call",
"failed_to_open_maps": "Failed to open maps application",
@@ -594,7 +656,8 @@
"set_as_current_call": "Set as Current Call",
"show_all": "Show All",
"hide_all": "Hide All",
- "view_call_details": "View Call Details"
+ "view_call_details": "View Call Details",
+ "view_poi_details": "View POI Details"
},
"notes": {
"actions": {
@@ -788,18 +851,24 @@
},
"status": {
"add_note": "Add Note",
+ "all_destinations_enabled": "Can respond to calls, stations, or POIs",
"both_destinations_enabled": "Can respond to calls or stations",
"call_destination_enabled": "Can respond to calls",
+ "calls_and_pois_destination_enabled": "Can respond to calls or POIs",
"calls_tab": "Calls",
"failed_to_save_status": "Failed to save status. Please try again.",
"general_status": "General status without specific destination",
+ "loading_pois": "Loading POIs...",
"loading_stations": "Loading stations...",
"no_destination": "No Destination",
+ "no_pois_available": "No POIs available",
"no_stations_available": "No stations available",
"no_statuses_available": "No statuses available",
"note": "Note",
"note_optional": "Add an optional note for this status update",
"note_required": "Please enter a note for this status update",
+ "poi_destination_enabled": "Can respond to POIs",
+ "pois_tab": "POIs",
"select_destination": "Select Destination for {{status}}",
"select_destination_type": "Where would you like to respond?",
"select_status": "Select Status",
@@ -808,6 +877,7 @@
"selected_status": "Selected Status",
"set_status": "Set Status",
"station_destination_enabled": "Can respond to stations",
+ "stations_and_pois_destination_enabled": "Can respond to stations or POIs",
"stations_tab": "Stations",
"status_saved_successfully": "Status saved successfully!"
},
diff --git a/src/translations/es.json b/src/translations/es.json
index 1af8ae6..9db4808 100644
--- a/src/translations/es.json
+++ b/src/translations/es.json
@@ -127,6 +127,9 @@
"contact_info": "Información de contacto",
"contact_name": "Nombre del contacto",
"contact_phone": "Teléfono",
+ "destination": "Destino",
+ "destination_address": "Dirección del destino",
+ "destination_type": "Tipo de destino",
"edit_call": "Editar llamada",
"external_id": "ID externo",
"failed_to_open_maps": "Error al abrir la aplicación de mapas",
@@ -181,6 +184,7 @@
"video": "Video"
},
"timestamp": "Marca de tiempo",
+ "scheduled_on": "Fecha y hora de despacho programado",
"title": "Detalles de la llamada",
"type": "Tipo",
"unit": "Unidad",
@@ -220,6 +224,8 @@
"description": "Descripción",
"description_placeholder": "Introduce la descripción de la llamada",
"deselect": "Deseleccionar",
+ "destination": "Destino",
+ "destination_poi": "PDI de destino",
"directions": "Direcciones",
"dispatch_to": "Despachar A",
"dispatch_to_everyone": "Despachar a todo el personal disponible",
@@ -237,6 +243,7 @@
"invalid_type": "Tipo inválido seleccionado. Por favor seleccione un tipo de llamada válido.",
"loading": "Cargando llamadas...",
"loading_calls": "Cargando llamadas...",
+ "loading_destination_pois": "Cargando PDI de destino...",
"name": "Nombre",
"name_placeholder": "Introduce el nombre de la llamada",
"nature": "Naturaleza",
@@ -248,6 +255,8 @@
"no_calls": "No hay llamadas activas",
"no_calls_available": "No hay llamadas disponibles",
"no_calls_description": "No se encontraron llamadas activas. Seleccione una llamada activa para ver los detalles.",
+ "no_destination": "Sin destino",
+ "no_destination_pois_available": "No hay PDI de destino disponibles",
"no_location_message": "Esta llamada no tiene datos de ubicación disponibles para navegación.",
"no_location_title": "Ubicación No Disponible",
"no_open_calls": "No hay llamadas abiertas disponibles",
@@ -268,6 +277,7 @@
"select_address_placeholder": "Selecciona la dirección de la llamada",
"select_description": "Seleccionar descripción",
"select_dispatch_recipients": "Seleccionar Destinatarios de Despacho",
+ "select_destination_poi": "Selecciona el PDI de destino",
"select_location": "Seleccionar ubicación en el mapa",
"select_name": "Seleccionar nombre",
"select_nature": "Seleccionar naturaleza",
@@ -576,6 +586,7 @@
"recenter_map": "Recentrar mapa",
"set_as_current_call": "Establecer como llamada actual",
"view_call_details": "Ver detalles de la llamada",
+ "view_poi_details": "Ver detalles del PDI",
"dispatched_resources": "Despachados",
"search_personnel_placeholder": "Buscar personal...",
"search_calls_placeholder": "Buscar llamadas...",
@@ -608,6 +619,7 @@
"menu": {
"calls": "Llamadas",
"calls_list": "Lista de Llamadas",
+ "scheduled_calls": "Llamadas Programadas",
"contacts": "Contactos",
"home": "Inicio",
"map": "Mapa",
@@ -615,6 +627,7 @@
"messages": "Mensajes",
"new_call": "Nueva Llamada",
"personnel": "Personal",
+ "pois": "PDI",
"protocols": "Protocolos",
"settings": "Configuración",
"units": "Unidades",
@@ -652,6 +665,56 @@
"send_email": "Correo",
"custom_fields": "Información adicional"
},
+ "pois": {
+ "address": "Dirección",
+ "all_types": "Todos los tipos",
+ "destination": "Destino",
+ "details": "Detalles",
+ "detail_not_found": "PDI no encontrado",
+ "detail_not_found_description": "No se pudo cargar el punto de interés solicitado.",
+ "detail_title": "Detalles del PDI",
+ "empty": "No se encontraron PDI",
+ "empty_description": "Todavía no hay puntos de interés disponibles para tu departamento.",
+ "empty_filtered": "No hay PDI coincidentes",
+ "empty_filtered_description": "Prueba limpiar la búsqueda o elegir otro tipo de PDI.",
+ "filter_by_type": "Filtrar por tipo de PDI",
+ "invalid_poi": "PDI no válido",
+ "invalid_poi_description": "El identificador del PDI seleccionado no es válido.",
+ "loading": "Cargando PDI...",
+ "loading_detail": "Cargando detalles del PDI...",
+ "map": "Mapa",
+ "no_location": "Sin ubicación disponible",
+ "no_location_description": "Este PDI no tiene coordenadas utilizables.",
+ "no_location_for_routing": "No hay datos de ubicación disponibles para enrutar",
+ "note": "Nota",
+ "route_error": "No se pudo abrir la aplicación de mapas",
+ "search": "Buscar PDI...",
+ "sort": "Ordenar",
+ "sort_options": {
+ "address-asc": "Dirección",
+ "name-asc": "Nombre (A-Z)",
+ "name-desc": "Nombre (Z-A)",
+ "type-asc": "Tipo"
+ },
+ "title": "PDI",
+ "type": "Tipo",
+ "unknown_type": "Tipo desconocido",
+ "unnamed": "PDI sin nombre"
+ },
+ "scheduled_calls": {
+ "title": "Llamadas Programadas",
+ "loading": "Cargando llamadas programadas...",
+ "no_scheduled_calls": "No hay llamadas programadas",
+ "no_scheduled_calls_description": "No hay llamadas programadas pendientes en este momento.",
+ "search": "Buscar llamadas programadas...",
+ "scheduled_for": "Programada para",
+ "table_number": "Call #",
+ "table_name": "Name",
+ "table_type": "Type",
+ "table_priority": "Priority",
+ "table_address": "Address",
+ "table_scheduled": "Scheduled For"
+ },
"onboarding": {
"screen1": {
"title": "Resgrid Dispatch",
@@ -793,18 +856,24 @@
},
"status": {
"add_note": "Añadir Nota",
+ "all_destinations_enabled": "Puede responder a llamadas, estaciones o PDI",
"both_destinations_enabled": "Puede responder a llamadas o estaciones",
"call_destination_enabled": "Puede responder a llamadas",
+ "calls_and_pois_destination_enabled": "Puede responder a llamadas o PDI",
"calls_tab": "Llamadas",
"failed_to_save_status": "Error al guardar el estado. Por favor, inténtelo de nuevo.",
"general_status": "Estado general sin destino específico",
+ "loading_pois": "Cargando PDI...",
"loading_stations": "Cargando estaciones...",
"no_destination": "Sin Destino",
+ "no_pois_available": "No hay PDI disponibles",
"no_stations_available": "No hay estaciones disponibles",
"no_statuses_available": "No hay estados disponibles",
"note": "Nota",
"note_optional": "Añade una nota opcional para esta actualización de estado",
"note_required": "Por favor ingresa una nota para esta actualización de estado",
+ "poi_destination_enabled": "Puede responder a PDI",
+ "pois_tab": "PDI",
"select_destination": "Seleccionar Destino para {{status}}",
"select_destination_type": "¿A dónde te gustaría responder?",
"select_status": "Seleccionar Estado",
@@ -813,6 +882,7 @@
"selected_status": "Estado Seleccionado",
"set_status": "Establecer Estado",
"station_destination_enabled": "Puede responder a estaciones",
+ "stations_and_pois_destination_enabled": "Puede responder a estaciones o PDI",
"stations_tab": "Estaciones",
"status_saved_successfully": "¡Estado guardado exitosamente!"
},
diff --git a/src/translations/fr.json b/src/translations/fr.json
index bac655f..45f8435 100644
--- a/src/translations/fr.json
+++ b/src/translations/fr.json
@@ -582,6 +582,20 @@
"units": "Unités",
"weatherAlerts": "Alertes météo"
},
+ "scheduled_calls": {
+ "title": "Scheduled Calls",
+ "loading": "Loading scheduled calls...",
+ "no_scheduled_calls": "No scheduled calls",
+ "no_scheduled_calls_description": "There are no pending scheduled calls at this time.",
+ "search": "Search scheduled calls...",
+ "scheduled_for": "Scheduled for",
+ "table_number": "Call #",
+ "table_name": "Name",
+ "table_type": "Type",
+ "table_priority": "Priority",
+ "table_address": "Address",
+ "table_scheduled": "Scheduled For"
+ },
"map": {
"call_set_as_current": "Appel défini comme appel en cours",
"failed_to_open_maps": "Échec de l'ouverture de l'application de cartographie",
diff --git a/src/translations/it.json b/src/translations/it.json
index fa02d0d..21ecf27 100644
--- a/src/translations/it.json
+++ b/src/translations/it.json
@@ -582,6 +582,20 @@
"units": "Unità",
"weatherAlerts": "Allerte meteo"
},
+ "scheduled_calls": {
+ "title": "Scheduled Calls",
+ "loading": "Loading scheduled calls...",
+ "no_scheduled_calls": "No scheduled calls",
+ "no_scheduled_calls_description": "There are no pending scheduled calls at this time.",
+ "search": "Search scheduled calls...",
+ "scheduled_for": "Scheduled for",
+ "table_number": "Call #",
+ "table_name": "Name",
+ "table_type": "Type",
+ "table_priority": "Priority",
+ "table_address": "Address",
+ "table_scheduled": "Scheduled For"
+ },
"map": {
"call_set_as_current": "Intervento impostato come intervento corrente",
"failed_to_open_maps": "Impossibile aprire l'applicazione mappe",
diff --git a/src/translations/pl.json b/src/translations/pl.json
index 9bbb2ee..b5be5ac 100644
--- a/src/translations/pl.json
+++ b/src/translations/pl.json
@@ -582,6 +582,20 @@
"units": "Jednostki",
"weatherAlerts": "Alerty pogodowe"
},
+ "scheduled_calls": {
+ "title": "Scheduled Calls",
+ "loading": "Loading scheduled calls...",
+ "no_scheduled_calls": "No scheduled calls",
+ "no_scheduled_calls_description": "There are no pending scheduled calls at this time.",
+ "search": "Search scheduled calls...",
+ "scheduled_for": "Scheduled for",
+ "table_number": "Call #",
+ "table_name": "Name",
+ "table_type": "Type",
+ "table_priority": "Priority",
+ "table_address": "Address",
+ "table_scheduled": "Scheduled For"
+ },
"map": {
"call_set_as_current": "Zgłoszenie ustawione jako bieżące",
"failed_to_open_maps": "Nie udało się otworzyć aplikacji mapowej",
diff --git a/src/translations/sv.json b/src/translations/sv.json
index 3bf6d82..db03e87 100644
--- a/src/translations/sv.json
+++ b/src/translations/sv.json
@@ -582,6 +582,20 @@
"units": "Enheter",
"weatherAlerts": "Vädervarningar"
},
+ "scheduled_calls": {
+ "title": "Scheduled Calls",
+ "loading": "Loading scheduled calls...",
+ "no_scheduled_calls": "No scheduled calls",
+ "no_scheduled_calls_description": "There are no pending scheduled calls at this time.",
+ "search": "Search scheduled calls...",
+ "scheduled_for": "Scheduled for",
+ "table_number": "Call #",
+ "table_name": "Name",
+ "table_type": "Type",
+ "table_priority": "Priority",
+ "table_address": "Address",
+ "table_scheduled": "Scheduled For"
+ },
"map": {
"call_set_as_current": "Ärende angivet som aktuellt ärende",
"failed_to_open_maps": "Kunde inte öppna kartapplikation",
diff --git a/src/translations/uk.json b/src/translations/uk.json
index edddceb..e9f92cd 100644
--- a/src/translations/uk.json
+++ b/src/translations/uk.json
@@ -582,6 +582,20 @@
"units": "Підрозділи",
"weatherAlerts": "Погодні попередження"
},
+ "scheduled_calls": {
+ "title": "Scheduled Calls",
+ "loading": "Loading scheduled calls...",
+ "no_scheduled_calls": "No scheduled calls",
+ "no_scheduled_calls_description": "There are no pending scheduled calls at this time.",
+ "search": "Search scheduled calls...",
+ "scheduled_for": "Scheduled for",
+ "table_number": "Call #",
+ "table_name": "Name",
+ "table_type": "Type",
+ "table_priority": "Priority",
+ "table_address": "Address",
+ "table_scheduled": "Scheduled For"
+ },
"map": {
"call_set_as_current": "Виклик встановлено як поточний",
"failed_to_open_maps": "Не вдалося відкрити картографічний додаток",
diff --git a/yarn.lock b/yarn.lock
index 528d7f1..a87e207 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1378,10 +1378,10 @@
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2"
integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==
-"@expo/cli@54.0.23":
- version "54.0.23"
- resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-54.0.23.tgz#e8a7dc4e1f2a8a5361afd80bcc352014b57a87ac"
- integrity sha512-km0h72SFfQCmVycH/JtPFTVy69w6Lx1cHNDmfLfQqgKFYeeHTjx7LVDP4POHCtNxFP2UeRazrygJhlh4zz498g==
+"@expo/cli@54.0.24":
+ version "54.0.24"
+ resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-54.0.24.tgz#7225d99e019f6eb85fd5ef018d8d82391be5dc82"
+ integrity sha512-5xse1bEgnVUBhOrtttc6xTNJVvjyTRavpzuF0/0nuj+312vfSbk7EiRbG+xJ2pW/iZxnhLPJkFCrPYG0nmheAQ==
dependencies:
"@0no-co/graphql.web" "^1.0.8"
"@expo/code-signing-certificates" "^0.0.6"
@@ -1392,7 +1392,7 @@
"@expo/image-utils" "^0.8.8"
"@expo/json-file" "^10.0.8"
"@expo/metro" "~54.2.0"
- "@expo/metro-config" "~54.0.14"
+ "@expo/metro-config" "~54.0.15"
"@expo/osascript" "^2.3.8"
"@expo/package-manager" "^1.9.10"
"@expo/plist" "^0.4.8"
@@ -1415,16 +1415,16 @@
connect "^3.7.0"
debug "^4.3.4"
env-editor "^0.4.1"
- expo-server "^1.0.5"
+ expo-server "^1.0.6"
freeport-async "^2.0.0"
getenv "^2.0.0"
glob "^13.0.0"
- lan-network "^0.1.6"
+ lan-network "^0.2.1"
minimatch "^9.0.0"
node-forge "^1.3.3"
npm-package-arg "^11.0.0"
ora "^3.4.0"
- picomatch "^3.0.1"
+ picomatch "^4.0.3"
pretty-bytes "^5.6.0"
pretty-format "^29.7.0"
progress "^2.0.3"
@@ -1479,7 +1479,7 @@
resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-54.0.10.tgz#688f4338255d2fdea970f44e2dfd8e8d37dec292"
integrity sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==
-"@expo/config@~12.0.11", "@expo/config@~12.0.12", "@expo/config@~12.0.13":
+"@expo/config@~12.0.12", "@expo/config@~12.0.13":
version "12.0.13"
resolved "https://registry.yarnpkg.com/@expo/config/-/config-12.0.13.tgz#8e696e6121c3c364e1dd527f595cf0a1d9386828"
integrity sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ==
@@ -1524,10 +1524,10 @@
dotenv-expand "~11.0.6"
getenv "^2.0.0"
-"@expo/fingerprint@0.15.4":
- version "0.15.4"
- resolved "https://registry.yarnpkg.com/@expo/fingerprint/-/fingerprint-0.15.4.tgz#578bd1e1179a13313f7de682ebaaacb703b2b7ac"
- integrity sha512-eYlxcrGdR2/j2M6pEDXo9zU9KXXF1vhP+V+Tl+lyY+bU8lnzrN6c637mz6Ye3em2ANy8hhUR03Raf8VsT9Ogng==
+"@expo/fingerprint@0.15.5":
+ version "0.15.5"
+ resolved "https://registry.yarnpkg.com/@expo/fingerprint/-/fingerprint-0.15.5.tgz#76968191026346657d22cea10d957aa8be97777e"
+ integrity sha512-mdVoAMcux1WlM6kd1RoWiHRNqKqS+J6mKmWQ/BKgeh937S/fcW58EE68O6nc4KDXtWi3PBeNHskOFcgyIuD4hw==
dependencies:
"@expo/spawn-async" "^1.7.2"
arg "^5.0.2"
@@ -1536,7 +1536,7 @@
getenv "^2.0.0"
glob "^13.0.0"
ignore "^5.3.1"
- minimatch "^9.0.0"
+ minimatch "^10.2.2"
p-limit "^3.1.0"
resolve-from "^5.0.0"
semver "^7.6.0"
@@ -1567,10 +1567,10 @@
"@babel/code-frame" "^7.20.0"
json5 "^2.2.3"
-"@expo/metro-config@54.0.14", "@expo/metro-config@~54.0.14":
- version "54.0.14"
- resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-54.0.14.tgz#e455dfb2bae9473ec665bc830d651baa709c1e8a"
- integrity sha512-hxpLyDfOR4L23tJ9W1IbJJsG7k4lv2sotohBm/kTYyiG+pe1SYCAWsRmgk+H42o/wWf/HQjE5k45S5TomGLxNA==
+"@expo/metro-config@54.0.15", "@expo/metro-config@~54.0.15":
+ version "54.0.15"
+ resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-54.0.15.tgz#aafdd2c2627fa60927e2d307f4d8cd303b6c5169"
+ integrity sha512-SqIya4VZ9KHM1S9g+xR0A+QKw1Tfs7Gacx6bQNJ98vs4+O7I5+QP5mHZIB0QSZLUV8opiXebHYTiTu+0OAsIUw==
dependencies:
"@babel/code-frame" "^7.20.0"
"@babel/core" "^7.20.0"
@@ -1590,7 +1590,7 @@
hermes-parser "^0.29.1"
jsc-safe-url "^0.2.4"
lightningcss "^1.30.1"
- minimatch "^9.0.0"
+ picomatch "^4.0.3"
postcss "~8.4.32"
resolve-from "^5.0.0"
@@ -8520,29 +8520,29 @@ expo-application@~7.0.8:
resolved "https://registry.yarnpkg.com/expo-application/-/expo-application-7.0.8.tgz#320af0d6c39b331456d3bc833b25763c702d23db"
integrity sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==
-expo-asset@~12.0.12:
- version "12.0.12"
- resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-12.0.12.tgz#15eb7d92cd43cc81c37149e5bbcdc3091875a85b"
- integrity sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ==
+expo-asset@~12.0.13:
+ version "12.0.13"
+ resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-12.0.13.tgz#1974ed7abee2ad987a519dbdcbf7f0c647dddf5b"
+ integrity sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ==
dependencies:
"@expo/image-utils" "^0.8.8"
- expo-constants "~18.0.12"
+ expo-constants "~18.0.13"
expo-audio@~1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/expo-audio/-/expo-audio-1.1.1.tgz#7b9763118e321c5dfbf2771cd4a5b6790ce4fc8d"
integrity sha512-CPCpJ+0AEHdzWROc0f00Zh6e+irLSl2ALos/LPvxEeIcJw1APfBa4DuHPkL4CQCWsVe7EnUjFpdwpqsEUWcP0g==
-expo-auth-session@~7.0.10:
- version "7.0.10"
- resolved "https://registry.yarnpkg.com/expo-auth-session/-/expo-auth-session-7.0.10.tgz#37250576baf5d56f16b861fb7c387a990f8eaf45"
- integrity sha512-XDnKkudvhHSKkZfJ+KkodM+anQcrxB71i+h0kKabdLa5YDXTQ81aC38KRc3TMqmnBDHAu0NpfbzEVd9WDFY3Qg==
+expo-auth-session@~7.0.11:
+ version "7.0.11"
+ resolved "https://registry.yarnpkg.com/expo-auth-session/-/expo-auth-session-7.0.11.tgz#fa9510862030962eac0147708ef28d6c63df3f71"
+ integrity sha512-AhWtt/m9rb1Po77X/VBFbeE6UTgbm2vXP2iCblUSRsHCw2qD6lO0ulKUB8Xyxy9FtoI9yrNQ1iwCNgIIgo8VYQ==
dependencies:
expo-application "~7.0.8"
- expo-constants "~18.0.11"
- expo-crypto "~15.0.8"
- expo-linking "~8.0.10"
- expo-web-browser "~15.0.10"
+ expo-constants "~18.0.13"
+ expo-crypto "~15.0.9"
+ expo-linking "~8.0.12"
+ expo-web-browser "~15.0.11"
invariant "^2.2.4"
expo-av@~16.0.8:
@@ -8558,7 +8558,7 @@ expo-build-properties@~1.0.10:
ajv "^8.11.0"
semver "^7.6.0"
-expo-constants@~18.0.11, expo-constants@~18.0.12, expo-constants@~18.0.13:
+expo-constants@~18.0.13:
version "18.0.13"
resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-18.0.13.tgz#0117f1f3d43be7b645192c0f4f431fb4efc4803d"
integrity sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==
@@ -8566,42 +8566,42 @@ expo-constants@~18.0.11, expo-constants@~18.0.12, expo-constants@~18.0.13:
"@expo/config" "~12.0.13"
"@expo/env" "~2.0.8"
-expo-crypto@~15.0.8:
- version "15.0.8"
- resolved "https://registry.yarnpkg.com/expo-crypto/-/expo-crypto-15.0.8.tgz#339198aae149b3ccc0b44f7150d7261a3a1f5287"
- integrity sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==
+expo-crypto@~15.0.9:
+ version "15.0.9"
+ resolved "https://registry.yarnpkg.com/expo-crypto/-/expo-crypto-15.0.9.tgz#5600c2e08e9946a8b1dfc8ceaa9891714b79ca8a"
+ integrity sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==
dependencies:
base64-js "^1.3.0"
-expo-dev-client@~6.0.20:
- version "6.0.20"
- resolved "https://registry.yarnpkg.com/expo-dev-client/-/expo-dev-client-6.0.20.tgz#d5b65974785100ae7f2538e16701fa1ef55f5fad"
- integrity sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA==
+expo-dev-client@~6.0.21:
+ version "6.0.21"
+ resolved "https://registry.yarnpkg.com/expo-dev-client/-/expo-dev-client-6.0.21.tgz#244f2c6b618e0a3eead8cc25af3583dad30864f4"
+ integrity sha512-SWI6HD0pa4eJujkYFkvvpezUE1zmJXGLu+34azpu7+QJgO+FLutDYDj8BSTdeH/NYDEClDFjCGqVMcWETvmsCQ==
dependencies:
- expo-dev-launcher "6.0.20"
- expo-dev-menu "7.0.18"
+ expo-dev-launcher "6.0.21"
+ expo-dev-menu "7.0.19"
expo-dev-menu-interface "2.0.0"
- expo-manifests "~1.0.10"
+ expo-manifests "~1.0.11"
expo-updates-interface "~2.0.0"
-expo-dev-launcher@6.0.20:
- version "6.0.20"
- resolved "https://registry.yarnpkg.com/expo-dev-launcher/-/expo-dev-launcher-6.0.20.tgz#b2ce90ff6af4c4de28bc1ea595b0b504ed9b467d"
- integrity sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA==
+expo-dev-launcher@6.0.21:
+ version "6.0.21"
+ resolved "https://registry.yarnpkg.com/expo-dev-launcher/-/expo-dev-launcher-6.0.21.tgz#66e4ce9f5c0f35cd359f3f27831fb0ca39cb7e16"
+ integrity sha512-QZ9gcKMZbp6EsIhzS0QoGB8Cf4xeVJhjbNgWUwcoBIk8gshoFz8CkCQOnX+HNv2sSY3rdCaNpx3Xo0Rflyq7rA==
dependencies:
ajv "^8.11.0"
- expo-dev-menu "7.0.18"
- expo-manifests "~1.0.10"
+ expo-dev-menu "7.0.19"
+ expo-manifests "~1.0.11"
expo-dev-menu-interface@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/expo-dev-menu-interface/-/expo-dev-menu-interface-2.0.0.tgz#c0d6db65eb4abc44a2701bc2303744619ad05ca6"
integrity sha512-BvAMPt6x+vyXpThsyjjOYyjwfjREV4OOpQkZ0tNl+nGpsPfcY9mc6DRACoWnH9KpLzyIt3BOgh3cuy/h/OxQjw==
-expo-dev-menu@7.0.18:
- version "7.0.18"
- resolved "https://registry.yarnpkg.com/expo-dev-menu/-/expo-dev-menu-7.0.18.tgz#4f3e2dc20b82fc495afb602301b83fa16430f6b8"
- integrity sha512-4kTdlHrnZCAWCT6tZRQHSSjZ7vECFisL4T+nsG/GJDo/jcHNaOVGV5qPV9wzlTxyMk3YOPggRw4+g7Ownrg5eA==
+expo-dev-menu@7.0.19:
+ version "7.0.19"
+ resolved "https://registry.yarnpkg.com/expo-dev-menu/-/expo-dev-menu-7.0.19.tgz#0a95758e99a857d2a63858a47b3d6ab0bcd7120a"
+ integrity sha512-ju5MZiBCPhUKKvHy0ElZdnlhq01mkEEiR8jfrgQVvW26aWjzjLiOhppNAyXtvGbhk7WxJim3wYMiqFFrjGdfKA==
dependencies:
expo-dev-menu-interface "2.0.0"
@@ -8617,10 +8617,10 @@ expo-document-picker@~14.0.8:
resolved "https://registry.yarnpkg.com/expo-document-picker/-/expo-document-picker-14.0.8.tgz#ca1d99cc432c48e69a6390eb035f3301557e3699"
integrity sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==
-expo-file-system@~19.0.21:
- version "19.0.21"
- resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-19.0.21.tgz#e96a68107fb629cf0dd1906fe7b46b566ff13e10"
- integrity sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==
+expo-file-system@~19.0.22:
+ version "19.0.22"
+ resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-19.0.22.tgz#8e8f892b2e89a78102b2b90fc1af5bb6bad4f21b"
+ integrity sha512-l9pgahSc7sJD0bP9vBNeXvZjy8QKDpVHVxWmei/ESQOrzmoj5BidziqLVsyZdxsi+PfdbTtttLTAmddH/JafYA==
expo-font@~14.0.11:
version "14.0.11"
@@ -8641,10 +8641,10 @@ expo-image-manipulator@~14.0.8:
dependencies:
expo-image-loader "~6.0.0"
-expo-image-picker@~17.0.10:
- version "17.0.10"
- resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.10.tgz#b4a714971378b2813e53d97d8ca81ab2c32cdf30"
- integrity sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw==
+expo-image-picker@~17.0.11:
+ version "17.0.11"
+ resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.11.tgz#8f1537df946f7c19747ba9d0043ef7e3e8cb67dc"
+ integrity sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ==
dependencies:
expo-image-loader "~6.0.0"
@@ -8663,12 +8663,12 @@ expo-keep-awake@~15.0.8:
resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d"
integrity sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ==
-expo-linking@~8.0.10, expo-linking@~8.0.11:
- version "8.0.11"
- resolved "https://registry.yarnpkg.com/expo-linking/-/expo-linking-8.0.11.tgz#b13ca9fc409ef0536352443221eb50e5e2bee366"
- integrity sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==
+expo-linking@~8.0.12:
+ version "8.0.12"
+ resolved "https://registry.yarnpkg.com/expo-linking/-/expo-linking-8.0.12.tgz#773bb2d18b89750af87fa807a6298d519130bdb6"
+ integrity sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==
dependencies:
- expo-constants "~18.0.12"
+ expo-constants "~18.0.13"
invariant "^2.2.4"
expo-localization@~17.0.8:
@@ -8683,18 +8683,18 @@ expo-location@~19.0.8:
resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-19.0.8.tgz#1805393151b1286021c1ad36246b6fd095d09b55"
integrity sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA==
-expo-manifests@~1.0.10:
- version "1.0.10"
- resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-1.0.10.tgz#5dfb3db1cdf6b46fee349f1d68a25edf5e087994"
- integrity sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ==
+expo-manifests@~1.0.11:
+ version "1.0.11"
+ resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-1.0.11.tgz#ffb21e433526a4daa7b3709b852df436e8fe46d1"
+ integrity sha512-6zItytTewN37Cjhp3glUg0ozrgW2GwB8x9wtfzUNoJIMmxO38nnGdTLMaotYhRqdf5PP2Dzdmej1HDHXVNUpRw==
dependencies:
- "@expo/config" "~12.0.11"
+ "@expo/config" "~12.0.13"
expo-json-utils "~0.15.0"
-expo-modules-autolinking@3.0.24:
- version "3.0.24"
- resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz#55fdfe1ef5a053d5cc287582170a5f6d69ab0e30"
- integrity sha512-TP+6HTwhL7orDvsz2VzauyQlXJcAWyU3ANsZ7JGL4DQu8XaZv/A41ZchbtAYLfozNA2Ya1Hzmhx65hXryBMjaQ==
+expo-modules-autolinking@3.0.25:
+ version "3.0.25"
+ resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz#3cb6e88fb491f15e3152c03eb64964e0b22fb707"
+ integrity sha512-YmHWctJlwvOuLZccg3cOXvSiXVJrPMKl7g2YR0YHWoGL9v2RvcmgaPJWPSLVW+voNEgEPsbo5UmUrAqbnYcBeg==
dependencies:
"@expo/spawn-async" "^1.7.2"
chalk "^4.1.0"
@@ -8702,10 +8702,10 @@ expo-modules-autolinking@3.0.24:
require-from-string "^2.0.2"
resolve-from "^5.0.0"
-expo-modules-core@3.0.29:
- version "3.0.29"
- resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-3.0.29.tgz#99287eba52f21784bcb2e4f4edd4fc4c21b5b265"
- integrity sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q==
+expo-modules-core@3.0.30:
+ version "3.0.30"
+ resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-3.0.30.tgz#ec4e216a33083afb0ddf314615d6d31a741984d6"
+ integrity sha512-a6IrpAn/Jbmwxi9L+hMmXKpNqnkUpoF7WHOpn02rVLyax2J0gB1vvCVE5rNydplEnt41Q6WxQwvcOjZaIkcSUg==
dependencies:
invariant "^2.2.4"
@@ -8718,10 +8718,10 @@ expo-navigation-bar@~5.0.10:
debug "^4.3.2"
react-native-is-edge-to-edge "^1.2.1"
-expo-notifications@~0.32.16:
- version "0.32.16"
- resolved "https://registry.yarnpkg.com/expo-notifications/-/expo-notifications-0.32.16.tgz#1a1304b89efedd7cdeba92de39b21f09fb2db2c7"
- integrity sha512-QQD/UA6v7LgvwIJ+tS7tSvqJZkdp0nCSj9MxsDk/jU1GttYdK49/5L2LvE/4U0H7sNBz1NZAyhDZozg8xgBLXw==
+expo-notifications@~0.32.17:
+ version "0.32.17"
+ resolved "https://registry.yarnpkg.com/expo-notifications/-/expo-notifications-0.32.17.tgz#7c9786f167da39d504edc450a84bcb5489c1a54e"
+ integrity sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A==
dependencies:
"@expo/image-utils" "^0.8.8"
"@ide/backoff" "^1.0.0"
@@ -8760,16 +8760,21 @@ expo-router@~6.0.23:
use-latest-callback "^0.2.1"
vaul "^1.1.2"
-expo-screen-orientation@~9.0.8:
- version "9.0.8"
- resolved "https://registry.yarnpkg.com/expo-screen-orientation/-/expo-screen-orientation-9.0.8.tgz#15b8f85bd4d183831943fc5a21e3020e17432867"
- integrity sha512-qRoPi3E893o3vQHT4h1NKo51+7g2hjRSbDeg1fsSo/u2pOW5s6FCeoacLvD+xofOP33cH2MkE4ua54aWWO7Icw==
+expo-screen-orientation@~9.0.9:
+ version "9.0.9"
+ resolved "https://registry.yarnpkg.com/expo-screen-orientation/-/expo-screen-orientation-9.0.9.tgz#74ed82582006f72106b7165512cdfc01367433ba"
+ integrity sha512-UTU745XoqBvQJkZ820GTfMuOxlkppYqaeQ+x0wdu44cyrZiZugqe+egFF2+zC+SaxgAltU5EuO8GIj9xSF+YMw==
expo-server@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/expo-server/-/expo-server-1.0.5.tgz#2d52002199a2af99c2c8771d0657916004345ca9"
integrity sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==
+expo-server@^1.0.6:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/expo-server/-/expo-server-1.0.6.tgz#a821c55dd3f9f3f9bce0ba70d81bbc9fcc360ee8"
+ integrity sha512-vb5TBtskvEdzYuW79lATXutOEBfW5m6U4EFpNjCVZTnI7S//SAsLQkYEpn+EDfn84m6VQfzSGkIVR6YPaScKFA==
+
expo-sharing@~14.0.8:
version "14.0.8"
resolved "https://registry.yarnpkg.com/expo-sharing/-/expo-sharing-14.0.8.tgz#cfd5fcf77ab5f64cf3d192a40a925abb316d3545"
@@ -8809,34 +8814,34 @@ expo-updates-interface@~2.0.0:
resolved "https://registry.yarnpkg.com/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz#7721cb64c37bcb46b23827b2717ef451a9378749"
integrity sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==
-expo-web-browser@~15.0.10:
- version "15.0.10"
- resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-15.0.10.tgz#ee7fb59b4f143f262c13c020433a83444181f1a3"
- integrity sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==
+expo-web-browser@~15.0.11:
+ version "15.0.11"
+ resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-15.0.11.tgz#6a8134e2398031ef79c89f516b15a18103903ef5"
+ integrity sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==
-expo@^54.0.33:
- version "54.0.33"
- resolved "https://registry.yarnpkg.com/expo/-/expo-54.0.33.tgz#f7d572857323f5a8250a9afe245a487d2ee2735f"
- integrity sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==
+expo@~54.0.34:
+ version "54.0.34"
+ resolved "https://registry.yarnpkg.com/expo/-/expo-54.0.34.tgz#fb1c90ff9d65d58978198622808c66a2d3b66fcc"
+ integrity sha512-XkVHguZZDC8BcTQxHAd14/TQFbDp1Wt0Z/KApO9t68Ll5A127hLCPzU+a9gytfCIiyL/V1IpF1vIcOLKEVAoNQ==
dependencies:
"@babel/runtime" "^7.20.0"
- "@expo/cli" "54.0.23"
+ "@expo/cli" "54.0.24"
"@expo/config" "~12.0.13"
"@expo/config-plugins" "~54.0.4"
"@expo/devtools" "0.1.8"
- "@expo/fingerprint" "0.15.4"
+ "@expo/fingerprint" "0.15.5"
"@expo/metro" "~54.2.0"
- "@expo/metro-config" "54.0.14"
+ "@expo/metro-config" "54.0.15"
"@expo/vector-icons" "^15.0.3"
"@ungap/structured-clone" "^1.3.0"
babel-preset-expo "~54.0.10"
- expo-asset "~12.0.12"
+ expo-asset "~12.0.13"
expo-constants "~18.0.13"
- expo-file-system "~19.0.21"
+ expo-file-system "~19.0.22"
expo-font "~14.0.11"
expo-keep-awake "~15.0.8"
- expo-modules-autolinking "3.0.24"
- expo-modules-core "3.0.29"
+ expo-modules-autolinking "3.0.25"
+ expo-modules-core "3.0.30"
pretty-format "^29.7.0"
react-refresh "^0.14.2"
whatwg-url-without-unicode "8.0.0-3"
@@ -11305,10 +11310,10 @@ ky@^1.2.0:
resolved "https://registry.yarnpkg.com/ky/-/ky-1.11.0.tgz#85e80fab76034a7fc0790ee1e3d0897f69d882ab"
integrity sha512-NEyo0ICpS0cqSuyoJFMCnHOZJILqXsKhIZlHJGDYaH8OB5IFrGzuBpEwyoMZG6gUKMPrazH30Ax5XKaujvD8ag==
-lan-network@^0.1.6:
- version "0.1.7"
- resolved "https://registry.yarnpkg.com/lan-network/-/lan-network-0.1.7.tgz#9fcb9967c6d951f10b2f9a9ffabe4a312d63f69d"
- integrity sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==
+lan-network@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/lan-network/-/lan-network-0.2.1.tgz#e4764a0d17f6bd1f2794c838fa219526a1b756f8"
+ integrity sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==
latest-version@^9.0.0:
version "9.0.0"
@@ -13444,11 +13449,6 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
-picomatch@^3.0.1:
- version "3.0.1"
- resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-3.0.1.tgz#817033161def55ec9638567a2f3bbc876b3e7516"
- integrity sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==
-
picomatch@^4.0.2, picomatch@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"