diff --git a/.github/workflows/react-native-cicd.yml b/.github/workflows/react-native-cicd.yml index 4f3f408d..0d925832 100644 --- a/.github/workflows/react-native-cicd.yml +++ b/.github/workflows/react-native-cicd.yml @@ -97,15 +97,17 @@ jobs: node-version: '24' cache: 'yarn' - - name: 📦 Setup yarn cache - uses: actions/cache@v3 + # node_modules is post-patch-package state, so patches/ must be part of the key: + # restoring an already-patched tree built from a different patches/ revision makes + # the re-apply fail. No restore-keys for the same reason — a prefix match would hand + # back node_modules patched by some other revision. The yarn tarball cache is handled + # by setup-node's `cache: yarn` above, so a key miss here is only a re-link, not a + # re-download. + - name: 📦 Setup node_modules cache + uses: actions/cache@v4 with: - path: | - ~/.cache/yarn - node_modules - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- + path: node_modules + key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock', 'patches/**') }} - name: 📦 Install dependencies run: yarn install --frozen-lockfile @@ -172,15 +174,17 @@ jobs: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} - - name: 📦 Setup yarn cache - uses: actions/cache@v3 + # node_modules is post-patch-package state, so patches/ must be part of the key: + # restoring an already-patched tree built from a different patches/ revision makes + # the re-apply fail. No restore-keys for the same reason — a prefix match would hand + # back node_modules patched by some other revision. The yarn tarball cache is handled + # by setup-node's `cache: yarn` above, so a key miss here is only a re-link, not a + # re-download. + - name: 📦 Setup node_modules cache + uses: actions/cache@v4 with: - path: | - ~/.cache/yarn - node_modules - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- + path: node_modules + key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock', 'patches/**') }} - name: 📦 Install dependencies run: | diff --git a/src/__tests__/app/call/new/address-search.test.ts b/src/__tests__/app/call/new/address-search.test.ts index 2963e0ce..69d7cebc 100644 --- a/src/__tests__/app/call/new/address-search.test.ts +++ b/src/__tests__/app/call/new/address-search.test.ts @@ -118,6 +118,9 @@ describe('Address Search Logic', () => { NovuApplicationId: '', AnalyticsApiKey: '', AnalyticsHost: '', + MapCenterLatitude: 0, + MapCenterLongitude: 0, + MapCenterZoomLevel: 9, }; beforeEach(() => { @@ -166,6 +169,9 @@ describe('Address Search Logic', () => { NovuApplicationId: '', AnalyticsApiKey: '', AnalyticsHost: '', + MapCenterLatitude: 0, + MapCenterLongitude: 0, + MapCenterZoomLevel: 9, }; const result = await performAddressSearch('123 Main St', configWithoutKey); diff --git a/src/__tests__/app/call/new/coordinates-search.test.tsx b/src/__tests__/app/call/new/coordinates-search.test.tsx index 9d3e09d9..bad22b3b 100644 --- a/src/__tests__/app/call/new/coordinates-search.test.tsx +++ b/src/__tests__/app/call/new/coordinates-search.test.tsx @@ -128,6 +128,9 @@ describe('Coordinates Search Logic', () => { NovuApplicationId: '', AnalyticsApiKey: '', AnalyticsHost: '', + MapCenterLatitude: 0, + MapCenterLongitude: 0, + MapCenterZoomLevel: 9, }; beforeEach(() => { @@ -272,6 +275,9 @@ describe('Coordinates Search Logic', () => { NovuApplicationId: '', AnalyticsApiKey: '', AnalyticsHost: '', + MapCenterLatitude: 0, + MapCenterLongitude: 0, + MapCenterZoomLevel: 9, }; const result = await performCoordinatesSearch('40.7128, -74.0060', configWithoutKey); diff --git a/src/__tests__/app/call/new/plus-code-search.test.ts b/src/__tests__/app/call/new/plus-code-search.test.ts index 5bae52fb..da7452e7 100644 --- a/src/__tests__/app/call/new/plus-code-search.test.ts +++ b/src/__tests__/app/call/new/plus-code-search.test.ts @@ -91,6 +91,9 @@ describe('Plus Code Search Logic', () => { NovuApplicationId: '', AnalyticsApiKey: '', AnalyticsHost: '', + MapCenterLatitude: 0, + MapCenterLongitude: 0, + MapCenterZoomLevel: 9, }; beforeEach(() => { @@ -139,6 +142,9 @@ describe('Plus Code Search Logic', () => { NovuApplicationId: '', AnalyticsApiKey: '', AnalyticsHost: '', + MapCenterLatitude: 0, + MapCenterLongitude: 0, + MapCenterZoomLevel: 9, }; const result = await performPlusCodeSearch('849VCWC8+R9', configWithoutKey); diff --git a/src/__tests__/app/call/new/what3words.test.tsx b/src/__tests__/app/call/new/what3words.test.tsx index 8d2a8da0..a0e4367a 100644 --- a/src/__tests__/app/call/new/what3words.test.tsx +++ b/src/__tests__/app/call/new/what3words.test.tsx @@ -25,6 +25,9 @@ const mockConfig: GetConfigResultData = { NovuApplicationId: '', AnalyticsApiKey: '', AnalyticsHost: '', + MapCenterLatitude: 0, + MapCenterLongitude: 0, + MapCenterZoomLevel: 9, }; // Mock the core store diff --git a/src/api/calls/callPriorities.ts b/src/api/calls/callPriorities.ts index 4b0e03f7..a31fc58f 100644 --- a/src/api/calls/callPriorities.ts +++ b/src/api/calls/callPriorities.ts @@ -3,7 +3,7 @@ import { type CallPrioritiesResult } from '@/models/v4/callPriorities/callPriori import { createCachedApiEndpoint } from '../common/cached-client'; const callsPrioritesApi = createCachedApiEndpoint('/CallPriorities/GetAllCallPriorites', { - ttl: 60 * 1000 * 2880, // Cache for 2 days + ttl: 6 * 60 * 60 * 1000, // Cache for 6 hours -- reference data, changes rarely enabled: true, }); diff --git a/src/api/calls/callTypes.ts b/src/api/calls/callTypes.ts index 78ceb8f3..c96b0ed1 100644 --- a/src/api/calls/callTypes.ts +++ b/src/api/calls/callTypes.ts @@ -3,7 +3,7 @@ import { type CallTypesResult } from '@/models/v4/callTypes/callTypesResult'; import { createCachedApiEndpoint } from '../common/cached-client'; const callsTypesApi = createCachedApiEndpoint('/CallTypes/GetAllCallTypes', { - ttl: 60 * 1000 * 2880, // Cache for 2 days + ttl: 6 * 60 * 60 * 1000, // Cache for 6 hours -- reference data, changes rarely enabled: true, }); diff --git a/src/api/calls/newCallFieldPolicy.ts b/src/api/calls/newCallFieldPolicy.ts new file mode 100644 index 00000000..67833ebc --- /dev/null +++ b/src/api/calls/newCallFieldPolicy.ts @@ -0,0 +1,22 @@ +import { type NewCallFieldPolicyResultData } from '@/models/v4/calls/newCallFieldPolicyResultData'; + +import { createApiEndpoint } from '../common/client'; + +const getNewCallFieldPolicyApi = createApiEndpoint('/Calls/GetNewCallFieldPolicy'); + +interface NewCallFieldPolicyResult { + Data: NewCallFieldPolicyResultData | null; +} + +/** + * Fetches the department's new-call field policy. + * + * An empty rule list means the stock form — every field visible, nothing extra required — which is + * also what a failure degrades to, since hiding fields a dispatcher needs is far worse than showing + * one they were told to hide. The server enforces the same policy on save regardless. + */ +export const getNewCallFieldPolicy = async (signal?: AbortSignal): Promise => { + const response = await getNewCallFieldPolicyApi.get(undefined, signal); + + return response.data?.Data ?? { Rules: [] }; +}; diff --git a/src/api/common/cached-client.ts b/src/api/common/cached-client.ts index 480a9af6..71216d79 100644 --- a/src/api/common/cached-client.ts +++ b/src/api/common/cached-client.ts @@ -1,5 +1,4 @@ import { type AxiosResponse } from 'axios'; -import { Platform } from 'react-native'; import { cacheManager } from '@/lib/cache/cache-manager'; @@ -10,20 +9,57 @@ interface CacheConfig { enabled?: boolean; // Whether to use cache for this endpoint } +interface GetOptions { + /** Skip the cached copy and refresh from the server. Use for pull-to-refresh and retries. */ + forceRefresh?: boolean; +} + +/** + * True when a v4 payload carries no rows. + * + * The v4 controllers answer an empty list with HTTP 200 and `{ Data: [], Status: 'not_found' }`, so + * a permissions blip or a transient server-side failure looks identical to a real answer at this + * layer. Caching that meant a single bad response hid every unit and every dispatch recipient for + * the whole TTL, and the UI reported it as "there are none" rather than "we could not load them". + * Empty answers are cheap to re-fetch, so never keep one. + */ +const isEmptyPayload = (payload: unknown): boolean => { + if (payload === null || payload === undefined) { + return true; + } + + if (typeof payload !== 'object') { + return false; + } + + const body = payload as { Data?: unknown; Status?: unknown }; + + if (typeof body.Status === 'string' && body.Status.toLowerCase() === 'not_found') { + return true; + } + + if (!('Data' in body)) { + return false; + } + + if (body.Data === null || body.Data === undefined) { + return true; + } + + return Array.isArray(body.Data) && body.Data.length === 0; +}; + export const createCachedApiEndpoint = (endpoint: string, cacheConfig: CacheConfig = { enabled: true }) => { const api = createApiEndpoint(endpoint); const defaultTTL = 5 * 60 * 1000; // 5 minutes - // Disable caching on web platform for now to avoid MMKV issues - const isCacheEnabled = cacheConfig.enabled && Platform.OS !== 'web'; - return { - get: async (params?: Record): Promise> => { - if (!isCacheEnabled) { + get: async (params?: Record, options?: GetOptions): Promise> => { + if (!cacheConfig.enabled) { return api.get(params); } - try { + if (!options?.forceRefresh) { const cached = cacheManager.get(endpoint, params); if (cached) { return Promise.resolve({ @@ -34,16 +70,16 @@ export const createCachedApiEndpoint = (endpoint: string, cacheConfig: CacheConf config: {}, } as AxiosResponse); } - } catch (error) { - console.error('Cache read error, continuing without cache:', error); } const response = await api.get(params); - try { + if (isEmptyPayload(response.data)) { + // A previously cached non-empty answer must not outlive an empty one, or the next read + // silently reverts to stale rows. + cacheManager.remove(endpoint, params); + } else { cacheManager.set(endpoint, response.data, params, cacheConfig.ttl || defaultTTL); - } catch (error) { - console.error('Cache write error, continuing without caching:', error); } return response; diff --git a/src/api/contacts/contacts.ts b/src/api/contacts/contacts.ts index 23d7ce08..0f688f15 100644 --- a/src/api/contacts/contacts.ts +++ b/src/api/contacts/contacts.ts @@ -8,12 +8,12 @@ import { createApiEndpoint } from '../common/client'; // Define API endpoints const getAllContactsApi = createCachedApiEndpoint('/Contacts/GetAllContacts', { - ttl: 60 * 1000 * 1440, // Cache for 1 day + ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale enabled: true, }); const getAllContactCategoriesApi = createCachedApiEndpoint('/Contacts/GetAllContactCategories', { - ttl: 60 * 1000 * 1440, // Cache for 1 day + ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale enabled: true, }); diff --git a/src/api/geocoding/geocoding.ts b/src/api/geocoding/geocoding.ts new file mode 100644 index 00000000..42926f9e --- /dev/null +++ b/src/api/geocoding/geocoding.ts @@ -0,0 +1,101 @@ +import { createApiEndpoint } from '../common/client'; + +/** + * Geocoding is proxied through the Resgrid API rather than called from the client. + * + * Two reasons. First, the Dispatch app's config key never carried a Google Maps key, so every + * direct lookup failed with "Google Maps API key not configured" and surfaced as "Failed to search + * for address, please try again". Second, Google's Geocoding *web service* and the what3words API + * send no CORS headers, so the browser and Electron builds could not call them even with a key — + * only the native build ever worked. The server-side endpoints also keep the provider keys off the + * client entirely. + */ + +const forwardGeocodeApi = createApiEndpoint('/Geocoding/ForwardGeocode'); +const reverseGeocodeApi = createApiEndpoint('/Geocoding/ReverseGeocode'); +const what3WordsLookupApi = createApiEndpoint('/Geocoding/What3WordsLookup'); +const plusCodeLookupApi = createApiEndpoint('/Geocoding/PlusCodeLookup'); + +interface ForwardGeocodeResult { + Data: { + Latitude: number | null; + Longitude: number | null; + Address: string | null; + LookupSucceeded: boolean; + }; +} + +interface ReverseGeocodeResult { + Data: { + Address: string | null; + LookupSucceeded: boolean; + }; +} + +/** + * Shaped like a Google Geocoding result so the existing screens — which render a picker when more + * than one candidate comes back — keep working unchanged. The server resolves a single best match, + * so the list holds zero or one entry today. + */ +export interface GeocodeCandidate { + formatted_address: string; + geometry: { + location: { + lat: number; + lng: number; + }; + }; + place_id: string; +} + +export interface GeocodeLookup { + /** True when the lookup ran. False means it failed — a different message to "no match". */ + succeeded: boolean; + candidates: GeocodeCandidate[]; +} + +const toCandidates = (data: ForwardGeocodeResult['Data'] | undefined, fallbackAddress: string): GeocodeLookup => { + if (!data) { + return { succeeded: false, candidates: [] }; + } + + if (data.Latitude === null || data.Latitude === undefined || data.Longitude === null || data.Longitude === undefined) { + return { succeeded: data.LookupSucceeded === true, candidates: [] }; + } + + return { + succeeded: true, + candidates: [ + { + formatted_address: data.Address || fallbackAddress, + geometry: { location: { lat: data.Latitude, lng: data.Longitude } }, + place_id: `${data.Latitude},${data.Longitude}`, + }, + ], + }; +}; + +export const forwardGeocode = async (address: string): Promise => { + const response = await forwardGeocodeApi.get({ address }); + return toCandidates(response.data?.Data, address); +}; + +export const what3WordsLookup = async (words: string): Promise => { + const response = await what3WordsLookupApi.get({ words }); + return toCandidates(response.data?.Data, words); +}; + +export const plusCodeLookup = async (code: string): Promise => { + const response = await plusCodeLookupApi.get({ code }); + return toCandidates(response.data?.Data, code); +}; + +export const reverseGeocode = async (lat: number, lon: number): Promise<{ succeeded: boolean; address: string | null }> => { + const response = await reverseGeocodeApi.get({ lat, lon }); + const data = response.data?.Data; + + return { + succeeded: data?.LookupSucceeded === true, + address: data?.Address || null, + }; +}; diff --git a/src/api/groups/groups.ts b/src/api/groups/groups.ts index 794627d3..04c6b8d5 100644 --- a/src/api/groups/groups.ts +++ b/src/api/groups/groups.ts @@ -5,7 +5,7 @@ import { createCachedApiEndpoint } from '../common/cached-client'; import { createApiEndpoint } from '../common/client'; const getAllGroupsApi = createCachedApiEndpoint('/Groups/GetAllGroups', { - ttl: 60 * 1000 * 2880, // Cache for 2 days + ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale enabled: true, }); diff --git a/src/api/messaging/messages.ts b/src/api/messaging/messages.ts index e9ea77a8..cd9ce08e 100644 --- a/src/api/messaging/messages.ts +++ b/src/api/messaging/messages.ts @@ -3,7 +3,7 @@ import { type GetRecipientsResult } from '@/models/v4/messages/getRecipientsResu import { createCachedApiEndpoint } from '../common/cached-client'; const recipientsApi = createCachedApiEndpoint('/Messages/GetRecipients', { - ttl: 60 * 1000 * 1440, // Cache for 1 day + ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale enabled: true, }); diff --git a/src/api/notes/notes.ts b/src/api/notes/notes.ts index 01df0563..b159a8ea 100644 --- a/src/api/notes/notes.ts +++ b/src/api/notes/notes.ts @@ -8,17 +8,17 @@ import { createCachedApiEndpoint } from '../common/cached-client'; import { createApiEndpoint } from '../common/client'; const getAllNotesApi = createCachedApiEndpoint('/Notes/GetAllNotes', { - ttl: 60 * 1000 * 2880, // Cache for 2 days + ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale enabled: true, }); const getDispatchNoteApi = createCachedApiEndpoint('/Notes/GetDispatchNote', { - ttl: 60 * 1000 * 1440, // Cache for 1 days + ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale enabled: true, }); const getNoteCategoriesApi = createCachedApiEndpoint('/Notes/GetNoteCategories', { - ttl: 60 * 1000 * 2880, // Cache for 2 days + ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale enabled: true, }); diff --git a/src/api/personnel/personnel.ts b/src/api/personnel/personnel.ts index 379d1b70..35eebe6e 100644 --- a/src/api/personnel/personnel.ts +++ b/src/api/personnel/personnel.ts @@ -10,7 +10,7 @@ const getPersonnelInfoApi = createApiEndpoint('/Personnel/GetPersonnelInfo'); const getAllPersonnelInfosApi = createApiEndpoint('/Personnel/GetAllPersonnelInfos'); const ugetPersonnelFilterOptionsApi = createCachedApiEndpoint('/Personnel/GetPersonnelFilterOptions', { - ttl: 60 * 1000 * 2880, // Cache for 2 days + ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale enabled: true, }); diff --git a/src/api/protocols/protocols.ts b/src/api/protocols/protocols.ts index d99449c4..7c036aa6 100644 --- a/src/api/protocols/protocols.ts +++ b/src/api/protocols/protocols.ts @@ -4,7 +4,7 @@ import { createCachedApiEndpoint } from '../common/cached-client'; import { createApiEndpoint } from '../common/client'; const getAllProtocolsApi = createCachedApiEndpoint('/Protocols/GetAllProtocols', { - ttl: 60 * 1000 * 2880, // Cache for 2 days + ttl: 6 * 60 * 60 * 1000, // Cache for 6 hours -- reference data, changes rarely enabled: true, }); diff --git a/src/api/runcards/runcards.ts b/src/api/runcards/runcards.ts new file mode 100644 index 00000000..abd1ada8 --- /dev/null +++ b/src/api/runcards/runcards.ts @@ -0,0 +1,85 @@ +import { type DispatchRecommendationResultData } from '@/models/v4/runcards/dispatchRecommendationResultData'; +import { type RunCardResultData } from '@/models/v4/runcards/runCardResultData'; + +import { api, createApiEndpoint } from '../common/client'; + +/** + * Run card endpoints. + * + * Every one of these is gated server-side by the `Dispatch.RunCards` feature toggle: + * `GetRecommendation` answers 404 and `EscalateCall` answers 400 when the department has it off. + * Callers must check the flag before calling rather than relying on the error — see + * `isRunCardsEnabled` in the feature-flags store. + */ + +const getRecommendationApi = createApiEndpoint('/RunCards/GetRecommendation'); +const getAllRunCardsApi = createApiEndpoint('/RunCards/GetAllRunCards'); + +interface RunCardRecommendationResult { + Data: DispatchRecommendationResultData | null; + /** 'success' when a card matched; 'not_found' is a valid answer meaning no card applies. */ + Status?: string; +} + +interface RunCardsResult { + Data: RunCardResultData[]; +} + +export interface EscalateCallResultData { + Id: string; + /** False when no run card matched or the next alarm level adds nothing new. */ + Success: boolean; + /** Alarm level after the escalation; unchanged when Success is false. */ + NewAlarmLevel: number; + AddedUnits: number; + AddedPersonnel: number; +} + +export interface RecommendationRequest { + /** Call priority — system 0-3 or a DepartmentCallPriorityId. */ + priority: number; + /** Call type *name*; the server resolves it case-insensitively against the department's types. */ + type: string; + latitude?: number | null; + longitude?: number | null; + /** Alarm level whose requirements to fill. Levels below it are assumed already handled. */ + alarmLevel?: number; +} + +/** + * Previews what the department's run cards would dispatch for a prospective call. Nothing is + * dispatched. Returns null when no card matches — the caller falls back to the manual flow. + */ +export const getDispatchRecommendation = async (request: RecommendationRequest, signal?: AbortSignal): Promise => { + const response = await getRecommendationApi.get( + { + priority: request.priority, + type: request.type, + ...(typeof request.latitude === 'number' ? { latitude: request.latitude } : {}), + ...(typeof request.longitude === 'number' ? { longitude: request.longitude } : {}), + alarmLevel: request.alarmLevel ?? 1, + }, + signal + ); + + const data = response.data?.Data ?? null; + + // A result with no matched card carries empty collections and means "no card applies here"; + // collapsing it to null keeps that out of the UI entirely. + return data && data.MatchedRunCardId ? data : null; +}; + +/** All run cards for the department. Read-only here — authoring lives in the web admin. */ +export const getAllRunCards = async (signal?: AbortSignal): Promise => { + const response = await getAllRunCardsApi.get(undefined, signal); + return response.data?.Data ?? []; +}; + +/** + * "Strike Next Alarm": escalates the call to its next alarm level, additively dispatching that + * level's requirements and notifying only the newly added resources. + */ +export const escalateCall = async (callId: string, signal?: AbortSignal): Promise => { + const response = await api.put(`/Calls/EscalateCall?callId=${encodeURIComponent(callId)}`, undefined, { signal }); + return response.data; +}; diff --git a/src/api/units/unitRoles.ts b/src/api/units/unitRoles.ts index 7dffff16..ba6586b9 100644 --- a/src/api/units/unitRoles.ts +++ b/src/api/units/unitRoles.ts @@ -7,7 +7,7 @@ import { createCachedApiEndpoint } from '../common/cached-client'; import { createApiEndpoint } from '../common/client'; const getRolesForUnitApi = createCachedApiEndpoint('/UnitRoles/GetRolesForUnit', { - ttl: 60 * 1000 * 2880, // Cache for 2 days + ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale enabled: true, }); diff --git a/src/api/units/units.ts b/src/api/units/units.ts index 1bd8b462..a8b4efdf 100644 --- a/src/api/units/units.ts +++ b/src/api/units/units.ts @@ -5,22 +5,22 @@ import { type UnitsResult } from '@/models/v4/units/unitsResult'; import { createCachedApiEndpoint } from '../common/cached-client'; const unitsApi = createCachedApiEndpoint('/Units/GetAllUnits', { - ttl: 60 * 1000 * 2880, // Cache for 2 days + ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale enabled: true, }); const unitsInfosApi = createCachedApiEndpoint('/Units/GetAllUnitsInfos', { - ttl: 60 * 1000 * 2880, // Cache for 2 days + ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale enabled: true, }); const unitsFilterOptionsApi = createCachedApiEndpoint('/Units/GetUnitsFilterOptions', { - ttl: 60 * 1000 * 2880, // Cache for 2 days + ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale enabled: true, }); -export const getUnits = async () => { - const response = await unitsApi.get(); +export const getUnits = async (forceRefresh = false) => { + const response = await unitsApi.get(undefined, { forceRefresh }); return response.data; }; diff --git a/src/api/voice/index.ts b/src/api/voice/index.ts index b438cedc..b5cb0378 100644 --- a/src/api/voice/index.ts +++ b/src/api/voice/index.ts @@ -11,14 +11,14 @@ const getConnectToSessionApi = createApiEndpoint('/Voice/ConnectToSession'); const getCanConnectToVoiceSessionApi = createApiEndpoint('/Voice/CanConnectToVoiceSession'); //const getDepartmentVoiceSettingsApi = createCachedApiEndpoint('/Voice/GetDepartmentVoiceSettings', { -// ttl: 60 * 1000 * 2880, // Cache for 2 days +// ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale // enabled: true, //}); const getDepartmentVoiceSettingsApi = createApiEndpoint('/Voice/GetDepartmentVoiceSettings'); const getDepartmentAudioStreamsApi = createCachedApiEndpoint('/Voice/GetDepartmentAudioStreams', { - ttl: 60 * 1000 * 2880, // Cache for 2 days + ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale enabled: false, }); diff --git a/src/app/(app)/map.web.tsx b/src/app/(app)/map.web.tsx index 0fc29dee..41cbad84 100644 --- a/src/app/(app)/map.web.tsx +++ b/src/app/(app)/map.web.tsx @@ -13,6 +13,7 @@ import { useAnalytics } from '@/hooks/use-analytics'; import { MapLayerType, useMapLayers } from '@/hooks/use-map-layers'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; +import { getDepartmentMapCenter } from '@/lib/map-center'; import { getMapPinSummary, hasValidMapCoordinates } from '@/lib/map-markers'; import { createMapMarkerElement } from '@/lib/map-markers-web'; import { createDefaultVisiblePoiLayerIds, filterMapPinsByPoiLayers, getPoiMapLayerId } from '@/lib/poi-map-layers'; @@ -114,13 +115,17 @@ export default function MapWeb() { mapboxgl.accessToken = Env.MAPBOX_PUBKEY; - const initialCenter: [number, number] = userLongitude && userLatitude ? [userLongitude, userLatitude] : [-98.5795, 39.8283]; // Center of USA as fallback + // Department map center as fallback. Read once: two calls are two store reads, and the second + // could see a different config. + const departmentCenter = getDepartmentMapCenter(); + const initialCenter: [number, number] = userLongitude && userLatitude ? [userLongitude, userLatitude] : [departmentCenter.longitude, departmentCenter.latitude]; map.current = new mapboxgl.Map({ container: mapContainer.current, style: getMapStyle(), center: initialCenter, - zoom: userLatitude && userLongitude ? 12 : 3, + // The department configured a zoom to go with its center; a fixed 3 opens on the whole globe. + zoom: userLatitude && userLongitude ? 12 : departmentCenter.zoomLevel, }); map.current.addControl(new mapboxgl.NavigationControl(), 'top-right'); diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index ff48bc76..f9c40135 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -30,6 +30,8 @@ import ZeroState from '@/components/common/zero-state'; import { IncidentCommandTab } from '@/components/incident-command/incident-command-tab'; // Import a static map component instead of react-native-maps import StaticMap from '@/components/maps/static-map'; +import { AlarmLevelBadge } from '@/components/runcards/alarm-level-badge'; +import { EscalateAlarmButton } from '@/components/runcards/escalate-alarm-button'; import { FocusAwareStatusBar, SafeAreaView } from '@/components/ui'; import { Box } from '@/components/ui/box'; import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; @@ -359,6 +361,15 @@ export default function CallDetail() { {callPriority?.Name} + {/* Renders only once the call has been escalated past the first alarm. */} + {call.AlarmLevel > 1 ? ( + + {t('run_cards.alarm_level_label')} + + + + + ) : null} {t('call_detail.timestamp')} {formatDateForDisplay(parseDateISOString(call.LoggedOn), 'MMM d, h:mm a')} @@ -522,6 +533,17 @@ export default function CallDetail() { icon: , content: ( + {canUserCreateCalls && isCallActive(call.State) ? ( + + fetchCallDetail(callId)} + /> + + ) : null} {canUserCreateCalls && isCallActive(call.State) ? ( - - )} - /> - + {fieldPolicy.isVisible(NewCallFieldKeys.Note) ? ( + + toggleSection('note')} className="flex-row items-center justify-between p-4"> + {t('calls.note')} + {sectionsExpanded.note ? : } + + {sectionsExpanded.note ? ( + + + ( + + )} + /> + + + ) : null} + + ) : null} - {/* GPS Coordinates Field */} - - - {t('calls.coordinates')} - - ( - - - - - - - + + )} + /> + + ) : null} + + {/* GPS Coordinates Field */} + {showGeolocation ? ( + + + {t('calls.coordinates')} + + ( + + + + + + + + + )} + /> + + ) : null} + + {/* what3words Field */} + {showWhat3Words ? ( + + + {t('calls.what3words')} + + ( + + + + + + + + + )} + /> + + ) : null} + + {/* Plus Code Field */} + {showPlusCode ? ( + + + {t('calls.plus_code')} + + ( + + + + + + + + + )} + /> + + ) : null} + + {/* Map Preview — the map is how a dispatcher fills the geolocation in. */} + {showGeolocation ? ( + + {selectedLocation ? ( + + ) : ( + - - )} - /> - + )} + + ) : null} + + {showDestinationPoi ? ( + + + {t('calls.destination_poi')} + + ( + + )} + /> + {isLoadingDestinationPois ? {t('calls.loading_destination_pois')} : null} + {!isLoadingDestinationPois && destinationPois.length === 0 ? {t('calls.no_destination_pois_available')} : null} + + ) : null} + + ) : null} + + ) : null} - {/* what3words Field */} - - - {t('calls.what3words')} - - ( - - + {/* One card holds both contact fields, so it shows when either is enabled. */} + {fieldPolicy.isVisible(NewCallFieldKeys.ContactName) || fieldPolicy.isVisible(NewCallFieldKeys.ContactInfo) ? ( + + toggleSection('contact')} className="flex-row items-center justify-between p-4"> + + + {t('calls.contact_information', 'Contact Information')} + + {sectionsExpanded.contact ? : } + + {sectionsExpanded.contact ? ( + + + {/* The card shows when either field is enabled, so each one still guards itself. */} + {fieldPolicy.isVisible(NewCallFieldKeys.ContactName) ? ( + + + {t('calls.contact_name')} + + ( - + - - - - )} - /> - - - {/* Plus Code Field */} - - - {t('calls.plus_code')} - - ( - - + )} + /> + + ) : null} + {fieldPolicy.isVisible(NewCallFieldKeys.ContactInfo) ? ( + + + {t('calls.contact_info')} + + ( - + - - - - )} - /> - - - {/* Map Preview */} - - {selectedLocation ? ( - - ) : ( - - )} - - - - - {t('calls.destination_poi')} - - ( - - )} - /> - {isLoadingDestinationPois ? {t('calls.loading_destination_pois')} : null} - {!isLoadingDestinationPois && destinationPois.length === 0 ? {t('calls.no_destination_pois_available')} : null} - - - ) : null} - - - - toggleSection('contact')} className="flex-row items-center justify-between p-4"> - - - {t('calls.contact_information', 'Contact Information')} - - {sectionsExpanded.contact ? : } - - {sectionsExpanded.contact ? ( - - - - - {t('calls.contact_name')} - - ( - - - - )} - /> - - - - {t('calls.contact_info')} - - ( - - - - )} - /> - - - ) : null} - + )} + /> + + ) : null} + + ) : null} + + ) : null} {/* Protocols */} - - toggleSection('protocols')} className="flex-row items-center justify-between p-4"> - - - {t('calls.protocols.title', 'Protocols')} - {selectedProtocols.length > 0 ? ( - - {selectedProtocols.length} - - ) : null} - - {sectionsExpanded.protocols ? : } - - {sectionsExpanded.protocols ? ( - - - - ) : null} - + {fieldPolicy.isVisible(NewCallFieldKeys.Protocols) ? ( + + toggleSection('protocols')} className="flex-row items-center justify-between p-4"> + + + {t('calls.protocols.title', 'Protocols')} + {selectedProtocols.length > 0 ? ( + + {selectedProtocols.length} + + ) : null} + + {sectionsExpanded.protocols ? : } + + {sectionsExpanded.protocols ? ( + + + + ) : null} + + ) : null} {/* Linked Call */} - - toggleSection('linkedCall')} className="flex-row items-center justify-between p-4"> - - - {t('calls.linked_calls.title', 'Linked Call')} - {linkedCall ? ( - - #{linkedCall.number} - - ) : null} - - {sectionsExpanded.linkedCall ? : } - - {sectionsExpanded.linkedCall ? ( - - {linkedCall ? ( - - - #{linkedCall.number} — {linkedCall.name} - - - - ) : null} - - - ) : null} - + {fieldPolicy.isVisible(NewCallFieldKeys.LinkedCall) ? ( + + toggleSection('linkedCall')} className="flex-row items-center justify-between p-4"> + + + {t('calls.linked_calls.title', 'Linked Call')} + {linkedCall ? ( + + #{linkedCall.number} + + ) : null} + + {sectionsExpanded.linkedCall ? : } + + {sectionsExpanded.linkedCall ? ( + + {linkedCall ? ( + + + #{linkedCall.number} — {linkedCall.name} + + + + ) : null} + + + ) : null} + + ) : null} {/* Additional Fields (UDF) */} @@ -1157,25 +1252,38 @@ export default function NewCall() { ) : null} - - toggleSection('dispatch')} className="flex-row items-center justify-between p-4"> - {t('calls.dispatch_to')} - {sectionsExpanded.dispatch ? : } - - {sectionsExpanded.dispatch ? ( - - - - ) : null} - + {fieldPolicy.isVisible(NewCallFieldKeys.DispatchList) ? ( + + toggleSection('dispatch')} className="flex-row items-center justify-between p-4"> + {t('calls.dispatch_to')} + {sectionsExpanded.dispatch ? : } + + {sectionsExpanded.dispatch ? ( + + {runCardRecommendation.isRunCardsEnabled ? ( + + ) : null} + + + ) : null} + + ) : null} - diff --git a/src/app/call/new/index.web.tsx b/src/app/call/new/index.web.tsx index de441250..443e0a34 100644 --- a/src/app/call/new/index.web.tsx +++ b/src/app/call/new/index.web.tsx @@ -1,5 +1,4 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import axios from 'axios'; import { type Href, router, Stack } from 'expo-router'; import { BookOpenIcon, CalendarClockIcon, ChevronDownIcon, ChevronUpIcon, FileTextIcon, LinkIcon, MapPinIcon, PlusIcon, SearchIcon, UserIcon, XIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; @@ -12,6 +11,7 @@ import * as z from 'zod'; import { createCall } from '@/api/calls/calls'; import { getNewCallData } from '@/api/dispatch/dispatch'; import { getNewCallForm } from '@/api/forms/forms'; +import { forwardGeocode, plusCodeLookup, reverseGeocode, what3WordsLookup } from '@/api/geocoding/geocoding'; import { saveUdfValues } from '@/api/userDefinedFields/userDefinedFields'; import { CallFormRenderer } from '@/components/calls/call-form-renderer'; import { CallTemplatesModal, type TemplateSelection } from '@/components/calls/call-templates-modal'; @@ -23,6 +23,8 @@ import { UdfFieldsRenderer } from '@/components/calls/udf-fields-renderer'; import { Loading } from '@/components/common/loading'; import FullScreenLocationPicker from '@/components/maps/full-screen-location-picker'; import LocationPicker from '@/components/maps/location-picker'; +import { RecommendationPanel } from '@/components/runcards/recommendation-panel'; +import { useCallRecommendation } from '@/components/runcards/use-call-recommendation'; import { Box } from '@/components/ui/box'; import { Button, ButtonText } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; @@ -31,9 +33,28 @@ import { HStack } from '@/components/ui/hstack'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; +import { useNewCallFieldPolicy } from '@/hooks/use-new-call-field-policy'; import { useToast } from '@/hooks/use-toast'; import { getPoiDestinationOptionLabel } from '@/lib/poi-display'; import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { type NewCallFieldKey, NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData'; + +// The policy speaks in stable wire keys; a dispatcher told to fill in 'contactName' is being shown +// the protocol rather than their own form. Map each key back to the label this screen already puts +// on the field. Only the fields this screen renders appear here — anything else falls back to the +// raw key, which at least names something, rather than being dropped from the message. +const NEW_CALL_FIELD_LABEL_KEYS: Partial> = { + [NewCallFieldKeys.Address]: 'calls.address', + [NewCallFieldKeys.Geolocation]: 'calls.coordinates', + [NewCallFieldKeys.What3Words]: 'calls.what3words', + [NewCallFieldKeys.PlusCode]: 'calls.plus_code', + [NewCallFieldKeys.Note]: 'calls.note', + [NewCallFieldKeys.ContactName]: 'calls.contact_name', + [NewCallFieldKeys.ContactInfo]: 'calls.contact_info', + [NewCallFieldKeys.DestinationPoi]: 'calls.destination', + [NewCallFieldKeys.DispatchOn]: 'calls.scheduled_on', + [NewCallFieldKeys.DispatchList]: 'calls.dispatch_to', +}; import { type ContactResultData } from '@/models/v4/contacts/contactResultData'; import { type FormResultData } from '@/models/v4/forms/formResultData'; import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; @@ -295,6 +316,10 @@ export default function NewCallWeb() { roles: [], units: [], }); + + // The department's new-call field policy: hides fields it does not use and blocks submission + // until the ones it marked required have values. Unconfigured departments see the stock form. + const fieldPolicy = useNewCallFieldPolicy(); const [selectedLocation, setSelectedLocation] = useState<{ latitude: number; longitude: number; @@ -357,6 +382,8 @@ export default function NewCallWeb() { }, }); + const watchedPriority = watch('priority'); + const watchedType = watch('type'); const watchedAddress = watch('address'); const watchedCoordinates = watch('coordinates'); const watchedWhat3Words = watch('what3words'); @@ -411,6 +438,52 @@ export default function NewCallWeb() { try { setIsSubmitting(true); + // The policy arrives asynchronously and reads as "nothing required" until it lands, so a + // submit in that window would skip every field the department marked required. Hold the call + // back instead. Fail-open only applies once the lookup has finished one way or the other. + if (!fieldPolicy.isLoaded) { + setIsSubmitting(false); + toast.error(t('calls.field_policy_loading')); + return; + } + + // A location on the equator or the prime meridian has a zero coordinate, which is a real + // place, not a blank field — test that both are finite rather than truthy. + const hasGeolocation = Number.isFinite(data.latitude) && Number.isFinite(data.longitude); + + // The department may require fields beyond the built-in mandatory four. Enforced here for a + // clear message, and again on the server so an old build cannot slip an incomplete call past. + // DispatchOn belongs here, unlike on the other forms: this screen is the one that actually + // renders a scheduling input and sends ScheduledOn. + const missingFields = fieldPolicy.missingRequired({ + [NewCallFieldKeys.Address]: data.address, + [NewCallFieldKeys.Geolocation]: hasGeolocation ? `${data.latitude},${data.longitude}` : '', + [NewCallFieldKeys.What3Words]: data.what3words, + [NewCallFieldKeys.PlusCode]: data.plusCode, + [NewCallFieldKeys.Note]: data.note, + [NewCallFieldKeys.ContactName]: data.contactName, + [NewCallFieldKeys.ContactInfo]: data.contactInfo, + [NewCallFieldKeys.DestinationPoi]: data.destinationPoiId, + [NewCallFieldKeys.Protocols]: selectedProtocols.length > 0, + [NewCallFieldKeys.LinkedCall]: !!linkedCall, + [NewCallFieldKeys.DispatchOn]: data.scheduledOn, + [NewCallFieldKeys.DispatchList]: + dispatchSelection.everyone || dispatchSelection.units.length > 0 || dispatchSelection.users.length > 0 || dispatchSelection.groups.length > 0 || dispatchSelection.roles.length > 0, + }); + + if (missingFields.length > 0) { + setIsSubmitting(false); + + const missingLabels = missingFields.map((key) => { + const labelKey = NEW_CALL_FIELD_LABEL_KEYS[key]; + + return labelKey ? t(labelKey) : key; + }); + + toast.error(t('calls.required_fields_missing', { fields: missingLabels.join(', ') })); + return; + } + if (selectedLocation?.latitude && selectedLocation?.longitude) { data.latitude = selectedLocation.latitude; data.longitude = selectedLocation.longitude; @@ -480,7 +553,7 @@ export default function NewCallWeb() { setIsSubmitting(false); } }, - [selectedLocation, callPriorities, callTypes, toast, t, callFormData, linkedCall?.callId, udfValues] + [selectedLocation, callPriorities, callTypes, toast, t, callFormData, linkedCall, selectedProtocols.length, udfValues, fieldPolicy, dispatchSelection] ); // Keyboard shortcuts @@ -532,6 +605,19 @@ export default function NewCallWeb() { [setValue] ); + // Run card recommendation. Inert unless the department has Dispatch.RunCards enabled. + const runCardRecommendation = useCallRecommendation({ + priorityName: watchedPriority, + typeName: watchedType, + latitude: selectedLocation?.latitude ?? null, + longitude: selectedLocation?.longitude ?? null, + callPriorities, + }); + + const handleApplyRecommendation = useCallback(() => { + handleDispatchSelection(runCardRecommendation.applyToSelection(dispatchSelection)); + }, [runCardRecommendation, dispatchSelection, handleDispatchSelection]); + const getDispatchSummary = () => { if (dispatchSelection.everyone) { return t('calls.everyone'); @@ -580,13 +666,10 @@ export default function NewCallWeb() { setIsGeocodingAddress(true); try { - const apiKey = config?.GoogleMapsKey; - if (!apiKey) throw new Error('Google Maps API key not configured'); + const lookup = await forwardGeocode(address); - const response = await axios.get(`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}`); - - if (response.data.status === 'OK' && response.data.results.length > 0) { - const results = response.data.results; + if (lookup.candidates.length > 0) { + const results = lookup.candidates; if (results.length === 1) { const result = results[0]; handleLocationSelected({ @@ -600,7 +683,7 @@ export default function NewCallWeb() { setShowAddressSelection(true); } } else { - toast.error(t('calls.address_not_found')); + toast.error(t(lookup.succeeded ? 'calls.address_not_found' : 'calls.geocoding_error')); } } catch (err) { console.error('Error geocoding address:', err); @@ -634,20 +717,18 @@ export default function NewCallWeb() { setIsGeocodingWhat3Words(true); try { - const apiKey = config?.W3WKey; - if (!apiKey) throw new Error('what3words API key not configured'); - - const response = await axios.get(`https://api.what3words.com/v3/convert-to-coordinates?words=${encodeURIComponent(what3words)}&key=${apiKey}`); + const lookup = await what3WordsLookup(what3words); - if (response.data.coordinates) { + if (lookup.candidates.length > 0) { + const result = lookup.candidates[0]; handleLocationSelected({ - latitude: response.data.coordinates.lat, - longitude: response.data.coordinates.lng, - address: response.data.nearestPlace, + latitude: result.geometry.location.lat, + longitude: result.geometry.location.lng, + address: result.formatted_address, }); toast.success(t('calls.what3words_found')); } else { - toast.error(t('calls.what3words_not_found')); + toast.error(t(lookup.succeeded ? 'calls.what3words_not_found' : 'calls.what3words_geocoding_error')); } } catch (err) { console.error('Error geocoding what3words:', err); @@ -665,13 +746,10 @@ export default function NewCallWeb() { setIsGeocodingPlusCode(true); try { - const apiKey = config?.GoogleMapsKey; - if (!apiKey) throw new Error('Google Maps API key not configured'); - - const response = await axios.get(`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(plusCode)}&key=${apiKey}`); + const lookup = await plusCodeLookup(plusCode); - if (response.data.status === 'OK' && response.data.results.length > 0) { - const result = response.data.results[0]; + if (lookup.candidates.length > 0) { + const result = lookup.candidates[0]; handleLocationSelected({ latitude: result.geometry.location.lat, longitude: result.geometry.location.lng, @@ -679,7 +757,7 @@ export default function NewCallWeb() { }); toast.success(t('calls.plus_code_found')); } else { - toast.error(t('calls.plus_code_not_found')); + toast.error(t(lookup.succeeded ? 'calls.plus_code_not_found' : 'calls.plus_code_geocoding_error')); } } catch (err) { console.error('Error geocoding plus code:', err); @@ -713,17 +791,13 @@ export default function NewCallWeb() { setIsGeocodingCoordinates(true); try { - const apiKey = config?.GoogleMapsKey; - if (!apiKey) throw new Error('Google Maps API key not configured'); + const lookup = await reverseGeocode(latitude, longitude); - const response = await axios.get(`https://maps.googleapis.com/maps/api/geocode/json?latlng=${latitude},${longitude}&key=${apiKey}`); - - if (response.data.status === 'OK' && response.data.results.length > 0) { - const result = response.data.results[0]; + if (lookup.address) { handleLocationSelected({ latitude, longitude, - address: result.formatted_address, + address: lookup.address, }); toast.success(t('calls.coordinates_found')); } else { @@ -752,6 +826,15 @@ export default function NewCallWeb() { ); } + // Every rule the department can set drives its own control. The location card groups five of + // them, so it only disappears once the policy has hidden all five. + const showAddress = fieldPolicy.isVisible(NewCallFieldKeys.Address); + const showGeolocation = fieldPolicy.isVisible(NewCallFieldKeys.Geolocation); + const showWhat3Words = fieldPolicy.isVisible(NewCallFieldKeys.What3Words); + const showPlusCode = fieldPolicy.isVisible(NewCallFieldKeys.PlusCode); + const showDestinationPoi = fieldPolicy.isVisible(NewCallFieldKeys.DestinationPoi); + const showLocationCard = showAddress || showGeolocation || showWhat3Words || showPlusCode || showDestinationPoi; + return ( <> @@ -790,6 +873,7 @@ export default function NewCallWeb() { {/* Left Column - Call Details */} + {/* Name, nature, priority and type are always required, so this card never hides. */} toggleSection('callDetails')}> {t('calls.call_details')} @@ -861,86 +945,96 @@ export default function NewCallWeb() { - ( - - )} - /> + {fieldPolicy.isVisible(NewCallFieldKeys.Note) ? ( + ( + + )} + /> + ) : null} ) : null} {/* Schedule Dispatch */} - - toggleSection('scheduledDispatch')}> - - - {t('calls.schedule_dispatch')} - - {sectionsExpanded.scheduledDispatch ? : } - - {sectionsExpanded.scheduledDispatch ? ( - - {t('calls.schedule_dispatch_description')} - ( - - )} - /> - - ) : null} - - - {/* Contact Information */} - - toggleSection('contact')}> - {t('calls.contact_information')} - {sectionsExpanded.contact ? : } - - {sectionsExpanded.contact ? ( - - setShowContactPicker(true)}> - - - {t('calls.contact_picker.search_placeholder', 'Search contacts...')} - - + {fieldPolicy.isVisible(NewCallFieldKeys.DispatchOn) ? ( + + toggleSection('scheduledDispatch')}> + + + {t('calls.schedule_dispatch')} + + {sectionsExpanded.scheduledDispatch ? : } + + {sectionsExpanded.scheduledDispatch ? ( + + {t('calls.schedule_dispatch_description')} + ( + + )} + /> + + ) : null} + + ) : null} - - - ( - - )} - /> - - - ( - - )} - /> + {/* Contact Information — one card holds both fields, so it shows when either is enabled. */} + {fieldPolicy.isVisible(NewCallFieldKeys.ContactName) || fieldPolicy.isVisible(NewCallFieldKeys.ContactInfo) ? ( + + toggleSection('contact')}> + {t('calls.contact_information')} + {sectionsExpanded.contact ? : } + + {sectionsExpanded.contact ? ( + + setShowContactPicker(true)}> + + + {t('calls.contact_picker.search_placeholder', 'Search contacts...')} + + + + + {fieldPolicy.isVisible(NewCallFieldKeys.ContactName) ? ( + + ( + + )} + /> + + ) : null} + {fieldPolicy.isVisible(NewCallFieldKeys.ContactInfo) ? ( + + ( + + )} + /> + + ) : null} - - ) : null} - + ) : null} + + ) : null} {/* Call Form */} {callForm ? ( @@ -974,257 +1068,294 @@ export default function NewCallWeb() { {/* Right Column - Location, Dispatch, Protocols, Linked Call */} {/* Location Card */} - - toggleSection('location')}> - {t('calls.call_location')} - {sectionsExpanded.location ? : } - - {sectionsExpanded.location ? ( - - ( - { - if (e.key === 'Enter') { - e.preventDefault(); - handleAddressSearch(value || ''); - } - }} - rightElement={ - handleAddressSearch(value || '')} - style={StyleSheet.flatten([styles.searchButton, isGeocodingAddress ? styles.searchButtonDisabled : {}])} - disabled={isGeocodingAddress || !value?.trim()} - > - {isGeocodingAddress ? ... : } - - } - /> - )} - /> - - ( - { - if (e.key === 'Enter') { - e.preventDefault(); - handleCoordinatesSearch(value || ''); - } - }} - rightElement={ - handleCoordinatesSearch(value || '')} - style={StyleSheet.flatten([styles.searchButton, isGeocodingCoordinates ? styles.searchButtonDisabled : {}])} - disabled={isGeocodingCoordinates || !value?.trim()} - > - {isGeocodingCoordinates ? ... : } - - } - /> - )} - /> - - - + {showLocationCard ? ( + + toggleSection('location')}> + {t('calls.call_location')} + {sectionsExpanded.location ? : } + + {sectionsExpanded.location ? ( + + {showAddress ? ( ( { if (e.key === 'Enter') { e.preventDefault(); - handleWhat3WordsSearch(value || ''); + handleAddressSearch(value || ''); } }} rightElement={ handleWhat3WordsSearch(value || '')} - style={StyleSheet.flatten([styles.searchButton, isGeocodingWhat3Words ? styles.searchButtonDisabled : {}])} - disabled={isGeocodingWhat3Words || !value?.trim()} + onPress={() => handleAddressSearch(value || '')} + style={StyleSheet.flatten([styles.searchButton, isGeocodingAddress ? styles.searchButtonDisabled : {}])} + disabled={isGeocodingAddress || !value?.trim()} > - {isGeocodingWhat3Words ? ... : } + {isGeocodingAddress ? ... : } } /> )} /> - - + ) : null} + + {showGeolocation ? ( ( { if (e.key === 'Enter') { e.preventDefault(); - handlePlusCodeSearch(value || ''); + handleCoordinatesSearch(value || ''); } }} rightElement={ handlePlusCodeSearch(value || '')} - style={StyleSheet.flatten([styles.searchButton, isGeocodingPlusCode ? styles.searchButtonDisabled : {}])} - disabled={isGeocodingPlusCode || !value?.trim()} + onPress={() => handleCoordinatesSearch(value || '')} + style={StyleSheet.flatten([styles.searchButton, isGeocodingCoordinates ? styles.searchButtonDisabled : {}])} + disabled={isGeocodingCoordinates || !value?.trim()} > - {isGeocodingPlusCode ? ... : } + {isGeocodingCoordinates ? ... : } } /> )} /> - - - - {/* Map Preview */} - - {selectedLocation ? ( - - - setShowLocationPicker(true)}> - - {t('calls.expand_map')} - + ) : null} + + {showWhat3Words || showPlusCode ? ( + + {showWhat3Words ? ( + + ( + { + if (e.key === 'Enter') { + e.preventDefault(); + handleWhat3WordsSearch(value || ''); + } + }} + rightElement={ + handleWhat3WordsSearch(value || '')} + style={StyleSheet.flatten([styles.searchButton, isGeocodingWhat3Words ? styles.searchButtonDisabled : {}])} + disabled={isGeocodingWhat3Words || !value?.trim()} + > + {isGeocodingWhat3Words ? ... : } + + } + /> + )} + /> + + ) : null} + {showPlusCode ? ( + + ( + { + if (e.key === 'Enter') { + e.preventDefault(); + handlePlusCodeSearch(value || ''); + } + }} + rightElement={ + handlePlusCodeSearch(value || '')} + style={StyleSheet.flatten([styles.searchButton, isGeocodingPlusCode ? styles.searchButtonDisabled : {}])} + disabled={isGeocodingPlusCode || !value?.trim()} + > + {isGeocodingPlusCode ? ... : } + + } + /> + )} + /> + + ) : null} - ) : ( - setShowLocationPicker(true)}> - - {t('calls.select_location')} - - )} + ) : null} + + {/* Map Preview — the map is how a dispatcher fills the geolocation in. */} + {showGeolocation ? ( + + {selectedLocation ? ( + + + setShowLocationPicker(true)}> + + {t('calls.expand_map')} + + + ) : ( + setShowLocationPicker(true)}> + + {t('calls.select_location')} + + )} + + ) : null} + + {showDestinationPoi ? ( + <> + ( + 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} + + ) : null} - - ( - 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} - - ) : null} - + ) : null} + + ) : null} {/* Dispatch Card */} - - toggleSection('dispatch')}> - {t('calls.dispatch_to')} - {sectionsExpanded.dispatch ? : } - - {sectionsExpanded.dispatch ? ( - setShowDispatchModal(true)}> - {getDispatchSummary()} - + {fieldPolicy.isVisible(NewCallFieldKeys.DispatchList) ? ( + + toggleSection('dispatch')}> + {t('calls.dispatch_to')} + {sectionsExpanded.dispatch ? : } - ) : null} - + {sectionsExpanded.dispatch ? ( + + {runCardRecommendation.isRunCardsEnabled ? ( + + ) : null} + setShowDispatchModal(true)}> + {getDispatchSummary()} + + + + ) : null} + + ) : null} {/* Protocols */} - - toggleSection('protocols')}> - - {t('calls.protocols.title', 'Protocols')} - {selectedProtocols.length > 0 ? ( - - {selectedProtocols.length} - - ) : null} - - {sectionsExpanded.protocols ? : } - - {sectionsExpanded.protocols ? ( - setShowProtocolSelector(true)}> - - - {selectedProtocols.length > 0 ? `${selectedProtocols.length} ${t('calls.protocols.selected_count', 'selected')}` : t('calls.protocols.select', 'Select Protocols')} - + {fieldPolicy.isVisible(NewCallFieldKeys.Protocols) ? ( + + toggleSection('protocols')}> + + {t('calls.protocols.title', 'Protocols')} + {selectedProtocols.length > 0 ? ( + + {selectedProtocols.length} + + ) : null} + + {sectionsExpanded.protocols ? : } - ) : null} - - - {/* Linked Call */} - - toggleSection('linkedCall')}> - - {t('calls.linked_calls.title', 'Linked Call')} - {linkedCall ? ( - - #{linkedCall.number} - - ) : null} - - {sectionsExpanded.linkedCall ? : } - - {sectionsExpanded.linkedCall ? ( - - {linkedCall ? ( - - - #{linkedCall.number} — {linkedCall.name} - - setLinkedCall(null)}> - - - - ) : null} - setShowLinkedCallsModal(true)}> - + {sectionsExpanded.protocols ? ( + setShowProtocolSelector(true)}> + - {linkedCall ? t('calls.linked_calls.change', 'Change linked call') : t('calls.linked_calls.select', 'Link to existing call')} + {selectedProtocols.length > 0 ? `${selectedProtocols.length} ${t('calls.protocols.selected_count', 'selected')}` : t('calls.protocols.select', 'Select Protocols')} - - ) : null} - + ) : null} + + ) : null} + + {/* Linked Call */} + {fieldPolicy.isVisible(NewCallFieldKeys.LinkedCall) ? ( + + toggleSection('linkedCall')}> + + {t('calls.linked_calls.title', 'Linked Call')} + {linkedCall ? ( + + #{linkedCall.number} + + ) : null} + + {sectionsExpanded.linkedCall ? : } + + {sectionsExpanded.linkedCall ? ( + + {linkedCall ? ( + + + #{linkedCall.number} — {linkedCall.name} + + setLinkedCall(null)}> + + + + ) : null} + setShowLinkedCallsModal(true)}> + + + {linkedCall ? t('calls.linked_calls.change', 'Change linked call') : t('calls.linked_calls.select', 'Link to existing call')} + + + + ) : null} + + ) : null} @@ -1233,7 +1364,11 @@ export default function NewCallWeb() { router.back()}> {t('common.cancel')} - + {isSubmitting ? t('common.creating') : t('calls.create')} diff --git a/src/components/calls/__tests__/dispatch-selection-basic.test.tsx b/src/components/calls/__tests__/dispatch-selection-basic.test.tsx index e65b3493..24ddc8c6 100644 --- a/src/components/calls/__tests__/dispatch-selection-basic.test.tsx +++ b/src/components/calls/__tests__/dispatch-selection-basic.test.tsx @@ -22,6 +22,7 @@ jest.mock('@/stores/dispatch/store', () => ({ }, isLoading: false, error: null, + loadFailures: { users: false, groups: false, units: false }, searchQuery: '', fetchDispatchData: jest.fn(), setSelection: jest.fn(), diff --git a/src/components/calls/__tests__/dispatch-selection-modal.test.tsx b/src/components/calls/__tests__/dispatch-selection-modal.test.tsx index 637a4774..a3276b00 100644 --- a/src/components/calls/__tests__/dispatch-selection-modal.test.tsx +++ b/src/components/calls/__tests__/dispatch-selection-modal.test.tsx @@ -21,6 +21,7 @@ const mockDispatchStore = { }, isLoading: false, error: null, + loadFailures: { users: false, groups: false, units: false }, searchQuery: '', fetchDispatchData: jest.fn(), setSelection: jest.fn(), diff --git a/src/components/calls/dispatch-selection-modal.tsx b/src/components/calls/dispatch-selection-modal.tsx index b67c0e30..c0ac73b6 100644 --- a/src/components/calls/dispatch-selection-modal.tsx +++ b/src/components/calls/dispatch-selection-modal.tsx @@ -23,8 +23,26 @@ interface DispatchSelectionModalProps { export const DispatchSelectionModal: React.FC = ({ isVisible, onClose, onConfirm, initialSelection }) => { const { t } = useTranslation(); const { colorScheme } = useColorScheme(); - const { data, selection, isLoading, error, searchQuery, fetchDispatchData, setSelection, toggleEveryone, toggleUser, toggleGroup, toggleRole, toggleUnit, setSearchQuery, clearSelection, getFilteredData } = - useDispatchStore(); + const { + data, + selection, + isLoading, + error, + loadFailures, + searchQuery, + fetchDispatchData, + setSelection, + toggleEveryone, + toggleUser, + toggleGroup, + toggleRole, + toggleUnit, + setSearchQuery, + clearSelection, + getFilteredData, + } = useDispatchStore(); + + const hasLoadFailure = loadFailures.users || loadFailures.groups || loadFailures.units; const filteredData = useMemo(() => getFilteredData(), [getFilteredData]); @@ -105,6 +123,18 @@ export const DispatchSelectionModal: React.FC = ({ ) : ( + {/* Partial load warning — the sections that did load are still usable. */} + {hasLoadFailure && ( + + + {t('calls.dispatch_recipients_partial_load')} + fetchDispatchData(true)}> + {t('common.retry')} + + + + )} + {/* Everyone Option */} @@ -234,6 +264,14 @@ export const DispatchSelectionModal: React.FC = ({ {t('common.no_results_found')} )} + + {/* Everything loaded and there is genuinely nothing to pick beyond Everyone. Say so, rather + than leaving the dispatcher staring at a single option wondering what broke. */} + {!searchQuery && !hasLoadFailure && data.users.length === 0 && data.groups.length === 0 && data.roles.length === 0 && data.units.length === 0 && ( + + {t('calls.dispatch_recipients_empty')} + + )} )} diff --git a/src/components/dispatch-console/__tests__/active-calls-panel.test.tsx b/src/components/dispatch-console/__tests__/active-calls-panel.test.tsx index 3f04484c..1c833216 100644 --- a/src/components/dispatch-console/__tests__/active-calls-panel.test.tsx +++ b/src/components/dispatch-console/__tests__/active-calls-panel.test.tsx @@ -163,6 +163,8 @@ const mockCalls: CallResultData[] = [ Protocols: [], UdfValues: [], CheckInTimersEnabled: false, + AlarmLevel: 1, + ActiveRunCardId: null, }, { CallId: 'call-2', @@ -204,6 +206,8 @@ const mockCalls: CallResultData[] = [ Protocols: [], UdfValues: [], CheckInTimersEnabled: false, + AlarmLevel: 1, + ActiveRunCardId: null, }, { CallId: 'call-3', @@ -245,6 +249,8 @@ const mockCalls: CallResultData[] = [ Protocols: [], UdfValues: [], CheckInTimersEnabled: false, + AlarmLevel: 1, + ActiveRunCardId: null, }, ]; diff --git a/src/components/incident-command/command-map.web.tsx b/src/components/incident-command/command-map.web.tsx index dc6e75a5..4f2877a7 100644 --- a/src/components/incident-command/command-map.web.tsx +++ b/src/components/incident-command/command-map.web.tsx @@ -9,6 +9,7 @@ import { HStack } from '@/components/ui/hstack'; import { Input, InputField } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { Env } from '@/lib/env'; +import { getDepartmentMapCenter } from '@/lib/map-center'; import { IncidentCapabilities, IncidentMapAnnotationType } from '@/models/v4/incidentCommand/incidentCommandEnums'; import { useLocationStore } from '@/stores/app/location-store'; import { useIncidentCommandStore } from '@/stores/incident-command/store'; @@ -16,6 +17,9 @@ import { useToastStore } from '@/stores/toast/store'; const MAPBOX_GL_CSS_URL = 'https://api.mapbox.com/mapbox-gl-js/v3.1.2/mapbox-gl.css'; +/** Close enough to work a scene, used whenever the camera opens on a known incident location. */ +const INCIDENT_ZOOM = 13; + // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyGeoJson = any; @@ -58,13 +62,19 @@ export const CommandMap: React.FC = () => { const canManage = (capabilities & IncidentCapabilities.ManageAnnotations) === IncidentCapabilities.ManageAnnotations; const annotations = useMemo(() => (board?.Annotations ?? []).filter((a) => !a.DeletedOn), [board?.Annotations]); - const center = useMemo<[number, number]>(() => { + // A command post or a device fix is a specific spot, so it keeps the close incident zoom. Only the + // department fallback is a whole service area, and it carries the zoom the department chose. + const camera = useMemo<{ center: [number, number]; zoom: number }>(() => { const command = board?.Command; const lng = parseFloat(command?.CommandPostLongitude ?? ''); const lat = parseFloat(command?.CommandPostLatitude ?? ''); - if (!isNaN(lng) && !isNaN(lat) && (lng !== 0 || lat !== 0)) return [lng, lat]; - if (userLongitude && userLatitude) return [userLongitude, userLatitude]; - return [-98.5795, 39.8283]; + if (!isNaN(lng) && !isNaN(lat) && (lng !== 0 || lat !== 0)) return { center: [lng, lat], zoom: INCIDENT_ZOOM }; + if (userLongitude && userLatitude) return { center: [userLongitude, userLatitude], zoom: INCIDENT_ZOOM }; + + // Read once: two calls are two store reads, and the second could see a different config. + const departmentCenter = getDepartmentMapCenter(); + + return { center: [departmentCenter.longitude, departmentCenter.latitude], zoom: departmentCenter.zoomLevel }; }, [board?.Command, userLongitude, userLatitude]); useEffect(() => { @@ -89,8 +99,8 @@ export const CommandMap: React.FC = () => { map.current = new mapboxgl.Map({ container: mapContainer.current, style: 'mapbox://styles/mapbox/streets-v12', - center, - zoom: 13, + center: camera.center, + zoom: camera.zoom, }); map.current.addControl(new mapboxgl.NavigationControl(), 'top-right'); map.current.on('load', () => setIsReady(true)); diff --git a/src/components/maps/full-screen-location-picker.web.tsx b/src/components/maps/full-screen-location-picker.web.tsx index e78014f7..4a0bcc9c 100644 --- a/src/components/maps/full-screen-location-picker.web.tsx +++ b/src/components/maps/full-screen-location-picker.web.tsx @@ -7,6 +7,7 @@ import { Text } from 'react-native'; import { Button, ButtonText } from '@/components/ui/button'; import { Env } from '@/lib/env'; +import { getDepartmentMapCenter } from '@/lib/map-center'; // 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'; @@ -134,13 +135,16 @@ const FullScreenLocationPicker: React.FC = ({ ini mapboxgl.accessToken = Env.MAPBOX_PUBKEY; - const initialCenter: [number, number] = currentLocation ? [currentLocation.longitude, currentLocation.latitude] : [-98.5795, 39.8283]; + // Read once: two calls are two store reads, and the second could see a different config. + const departmentCenter = getDepartmentMapCenter(); + const initialCenter: [number, number] = currentLocation ? [currentLocation.longitude, currentLocation.latitude] : [departmentCenter.longitude, departmentCenter.latitude]; map.current = new mapboxgl.Map({ container: mapContainer.current, style: 'mapbox://styles/mapbox/streets-v12', center: initialCenter, - zoom: currentLocation ? 15 : 3, + // The department configured a zoom to go with its center; a fixed 3 opens on the whole globe. + zoom: currentLocation ? 15 : departmentCenter.zoomLevel, }); map.current.addControl(new mapboxgl.NavigationControl(), 'top-right'); diff --git a/src/components/maps/location-picker.web.tsx b/src/components/maps/location-picker.web.tsx index a5a01cf6..af14ee5f 100644 --- a/src/components/maps/location-picker.web.tsx +++ b/src/components/maps/location-picker.web.tsx @@ -6,6 +6,7 @@ import { Text } from 'react-native'; import { Button, ButtonText } from '@/components/ui/button'; import { Env } from '@/lib/env'; +import { getDepartmentMapCenter } from '@/lib/map-center'; // 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'; @@ -92,13 +93,16 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca mapboxgl.accessToken = Env.MAPBOX_PUBKEY; - const initialCenter: [number, number] = currentLocation ? [currentLocation.longitude, currentLocation.latitude] : [-98.5795, 39.8283]; + // Read once: two calls are two store reads, and the second could see a different config. + const departmentCenter = getDepartmentMapCenter(); + const initialCenter: [number, number] = currentLocation ? [currentLocation.longitude, currentLocation.latitude] : [departmentCenter.longitude, departmentCenter.latitude]; map.current = new mapboxgl.Map({ container: mapContainer.current, style: 'mapbox://styles/mapbox/streets-v12', center: initialCenter, - zoom: currentLocation ? 15 : 3, + // The department configured a zoom to go with its center; a fixed 3 opens on the whole globe. + zoom: currentLocation ? 15 : departmentCenter.zoomLevel, }); map.current.addControl(new mapboxgl.NavigationControl(), 'top-right'); diff --git a/src/components/maps/unified-map-view.tsx b/src/components/maps/unified-map-view.tsx index 197e2b77..57b325cd 100644 --- a/src/components/maps/unified-map-view.tsx +++ b/src/components/maps/unified-map-view.tsx @@ -6,6 +6,7 @@ import { StyleSheet, View } from 'react-native'; import { getMapDataAndMarkers } from '@/api/mapping/mapping'; import { logger } from '@/lib/logging'; +import { getDepartmentMapCenter } from '@/lib/map-center'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { type GetMapLayersData } from '@/models/v4/mapping/getMapLayersResultData'; import { useLocationStore } from '@/stores/app/location-store'; @@ -266,8 +267,10 @@ export const UnifiedMapView: React.FC = ({ onMapReady?.(); }; - // Initial camera position - const initialCenter: [number, number] = location.longitude && location.latitude ? [location.longitude, location.latitude] : [-98.5795, 39.8283]; + // Initial camera position. Read the department center once: two calls are two store reads, and + // the second could see a different config. + const departmentCenter = getDepartmentMapCenter(); + const initialCenter: [number, number] = location.longitude && location.latitude ? [location.longitude, location.latitude] : [departmentCenter.longitude, departmentCenter.latitude]; return ( @@ -281,7 +284,8 @@ export const UnifiedMapView: React.FC = ({ rotateEnabled={interactive} pitchEnabled={interactive} > - + {/* The department configured a zoom to go with its center; a fixed 3 opens on the whole globe. */} + {/* Render custom layers */} {renderMapLayers()} diff --git a/src/components/maps/unified-map-view.web.tsx b/src/components/maps/unified-map-view.web.tsx index 355ca987..38075acc 100644 --- a/src/components/maps/unified-map-view.web.tsx +++ b/src/components/maps/unified-map-view.web.tsx @@ -7,6 +7,7 @@ import { StyleSheet, View } from 'react-native'; import { getMapDataAndMarkers } from '@/api/mapping/mapping'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; +import { getDepartmentMapCenter } from '@/lib/map-center'; import { getMapPinSummary, hasValidMapCoordinates } from '@/lib/map-markers'; import { createMapMarkerElement } from '@/lib/map-markers-web'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; @@ -90,13 +91,16 @@ export const UnifiedMapView: React.FC = ({ mapboxgl.accessToken = Env.MAPBOX_PUBKEY; const { latitude, longitude } = useLocationStore.getState(); - const initialCenter: [number, number] = longitude && latitude ? [longitude, latitude] : [-98.5795, 39.8283]; + // Read once: two calls are two store reads, and the second could see a different config. + const departmentCenter = getDepartmentMapCenter(); + const initialCenter: [number, number] = longitude && latitude ? [longitude, latitude] : [departmentCenter.longitude, departmentCenter.latitude]; map.current = new mapboxgl.Map({ container: mapContainer.current, style: getMapStyle(), center: initialCenter, - zoom: latitude && longitude ? 12 : 3, + // The department configured a zoom to go with its center; a fixed 3 opens on the whole globe. + zoom: latitude && longitude ? 12 : departmentCenter.zoomLevel, interactive, }); diff --git a/src/components/runcards/__tests__/alarm-level-badge.test.tsx b/src/components/runcards/__tests__/alarm-level-badge.test.tsx new file mode 100644 index 00000000..1bfea502 --- /dev/null +++ b/src/components/runcards/__tests__/alarm-level-badge.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { AlarmLevelBadge } from '@/components/runcards/alarm-level-badge'; +import { useIsRunCardsEnabled } from '@/stores/feature-flags/store'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => (key === 'run_cards.alarm_level' ? `Alarm ${options?.level}` : key), + }), +})); + +jest.mock('@/stores/feature-flags/store', () => ({ + useIsRunCardsEnabled: jest.fn(() => true), +})); + +const mockedIsEnabled = useIsRunCardsEnabled as jest.Mock; + +describe('AlarmLevelBadge', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedIsEnabled.mockReturnValue(true); + }); + + it('renders the level once a call has been escalated', () => { + render(); + + expect(screen.getByText('Alarm 3')).toBeTruthy(); + }); + + it('stays hidden at the first alarm', () => { + // Every call is a first alarm; badging them all would bury the ones that matter. + render(); + + expect(screen.queryByTestId('alarm-level-badge')).toBeNull(); + }); + + it('stays hidden when run cards are disabled', () => { + // Alarm levels only move through a run card, so the field means nothing without the feature. + mockedIsEnabled.mockReturnValue(false); + + render(); + + expect(screen.queryByTestId('alarm-level-badge')).toBeNull(); + }); + + it('tolerates a missing level', () => { + render(); + + expect(screen.queryByTestId('alarm-level-badge')).toBeNull(); + }); +}); diff --git a/src/components/runcards/__tests__/use-call-recommendation.test.ts b/src/components/runcards/__tests__/use-call-recommendation.test.ts new file mode 100644 index 00000000..a0647e30 --- /dev/null +++ b/src/components/runcards/__tests__/use-call-recommendation.test.ts @@ -0,0 +1,94 @@ +import { act, renderHook } from '@testing-library/react-native'; + +import { getDispatchRecommendation } from '@/api/runcards/runcards'; +import { useCallRecommendation } from '@/components/runcards/use-call-recommendation'; +import { useRunCardsStore } from '@/stores/runcards/store'; + +jest.mock('@/api/runcards/runcards', () => ({ + getDispatchRecommendation: jest.fn(), + escalateCall: jest.fn(), +})); + +jest.mock('@/stores/feature-flags/store', () => ({ + isRunCardsEnabled: jest.fn(() => true), + useIsRunCardsEnabled: jest.fn(() => true), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn() }, +})); + +const mockedGetRecommendation = getDispatchRecommendation as jest.Mock; + +const callPriorities = [{ Id: 1, Name: 'High' }]; + +const args = (overrides: Partial[0]> = {}) => ({ + priorityName: 'High', + typeName: 'Structure Fire', + latitude: 51.1, + longitude: 3.8, + callPriorities, + ...overrides, +}); + +/** Past the store's 600 ms debounce, and flushing the fetch promise the timer starts. */ +const runDebounce = async () => { + await act(async () => { + jest.advanceTimersByTime(1000); + }); +}; + +describe('useCallRecommendation', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockedGetRecommendation.mockResolvedValue({ MatchedRunCardId: 3, Units: [], Personnel: [] }); + useRunCardsStore.getState().clear(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('cancels a pending lookup when the inputs stop being requestable', async () => { + // Clearing the call type mid-debounce must not let the timer fire with the priority and type + // the dispatcher just removed. + const { rerender } = renderHook((props: Parameters[0]) => useCallRecommendation(props), { initialProps: args() }); + + act(() => { + jest.advanceTimersByTime(300); + }); + expect(mockedGetRecommendation).not.toHaveBeenCalled(); + + rerender(args({ typeName: '' })); + await runDebounce(); + + expect(mockedGetRecommendation).not.toHaveBeenCalled(); + }); + + it('drops a recommendation already on screen once the inputs stop being requestable', async () => { + const { result, rerender } = renderHook((props: Parameters[0]) => useCallRecommendation(props), { initialProps: args() }); + + await runDebounce(); + expect(result.current.recommendation).not.toBeNull(); + + rerender(args({ priorityName: null })); + + expect(result.current.recommendation).toBeNull(); + expect(result.current.hasFetched).toBe(false); + }); + + it('replaces a pending lookup when the inputs change but stay requestable', async () => { + const { rerender } = renderHook((props: Parameters[0]) => useCallRecommendation(props), { initialProps: args() }); + + act(() => { + jest.advanceTimersByTime(300); + }); + + rerender(args({ typeName: 'Medical' })); + await runDebounce(); + + expect(mockedGetRecommendation).toHaveBeenCalledTimes(1); + expect(mockedGetRecommendation).toHaveBeenCalledWith(expect.objectContaining({ type: 'Medical', priority: 1 }), expect.anything()); + }); +}); diff --git a/src/components/runcards/alarm-level-badge.tsx b/src/components/runcards/alarm-level-badge.tsx new file mode 100644 index 00000000..51c8d4cf --- /dev/null +++ b/src/components/runcards/alarm-level-badge.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { useIsRunCardsEnabled } from '@/stores/feature-flags/store'; + +interface AlarmLevelBadgeProps { + /** Alarm level from the call payload. 1 (or 0 on pre-run-card calls) means never escalated. */ + alarmLevel: number | null | undefined; + testID?: string; +} + +/** + * Shows a call's alarm level once it has been escalated above the first. + * + * Hidden at level 1: every call is a first alarm, so badging them all would be noise that hides the + * handful that actually matter. Also hidden when run cards are off — alarm levels only move through + * a run card, so the field is meaningless to a department that does not use them. + */ +export const AlarmLevelBadge: React.FC = ({ alarmLevel, testID = 'alarm-level-badge' }) => { + const { t } = useTranslation(); + const isRunCardsEnabled = useIsRunCardsEnabled(); + + if (!isRunCardsEnabled || typeof alarmLevel !== 'number' || alarmLevel <= 1) { + return null; + } + + return ( + + {t('run_cards.alarm_level', { level: alarmLevel })} + + ); +}; diff --git a/src/components/runcards/escalate-alarm-button.tsx b/src/components/runcards/escalate-alarm-button.tsx new file mode 100644 index 00000000..991c673d --- /dev/null +++ b/src/components/runcards/escalate-alarm-button.tsx @@ -0,0 +1,111 @@ +import { FlameIcon } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { useIsRunCardsEnabled } from '@/stores/feature-flags/store'; +import { useRunCardsStore } from '@/stores/runcards/store'; +import { useToastStore } from '@/stores/toast/store'; + +interface EscalateAlarmButtonProps { + callId: string; + /** Current alarm level from the call payload; 1 when the call has never been escalated. */ + alarmLevel: number; + /** Null when no run card is driving this call — escalation has nothing to add. */ + activeRunCardId: number | null; + /** False for a closed call or a user without edit rights. */ + canEscalate: boolean; + onEscalated?: (newAlarmLevel: number) => void; + testID?: string; +} + +/** + * "Strike Next Alarm". + * + * Escalation is additive: the server dispatches only the *next* level's requirements and notifies + * only the newly added resources, so striking twice never re-alerts the crews already working the + * call. It is also irreversible from here — there is no "unstrike" — hence the two-step confirm. + * + * Renders nothing unless the department has `Dispatch.RunCards` on AND a card is actually driving + * this call: without a card the server would answer with a no-op, and offering a button that + * silently does nothing is worse than not offering it. + */ +export const EscalateAlarmButton: React.FC = ({ callId, alarmLevel, activeRunCardId, canEscalate, onEscalated, testID = 'escalate-alarm-button' }) => { + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === 'dark'; + const isRunCardsEnabled = useIsRunCardsEnabled(); + const escalate = useRunCardsStore((state) => state.escalate); + const isEscalating = useRunCardsStore((state) => state.isEscalating); + const showToast = useToastStore((state) => state.showToast); + const [isConfirming, setIsConfirming] = useState(false); + + const handlePress = useCallback(async () => { + if (!isConfirming) { + setIsConfirming(true); + return; + } + + setIsConfirming(false); + + const result = await escalate(callId); + + if (!result) { + showToast('error', t('run_cards.escalate_failed')); + return; + } + + if (!result.Success) { + // The card has no level beyond the current one, or every resource it would add is already + // on the call. Nothing went wrong — say so plainly instead of showing an error. + showToast('info', t('run_cards.escalate_nothing_to_add')); + return; + } + + showToast( + 'success', + t('run_cards.escalate_succeeded', { + level: result.NewAlarmLevel, + units: result.AddedUnits, + personnel: result.AddedPersonnel, + }) + ); + + onEscalated?.(result.NewAlarmLevel); + }, [isConfirming, escalate, callId, showToast, t, onEscalated]); + + const handleCancel = useCallback(() => setIsConfirming(false), []); + + if (!isRunCardsEnabled || !activeRunCardId || !canEscalate) { + return null; + } + + return ( + + {isConfirming ? ( + + {t('run_cards.escalate_confirm', { level: Math.max(1, alarmLevel) + 1 })} + + + + + + ) : ( + + )} + + ); +}; diff --git a/src/components/runcards/recommendation-panel.tsx b/src/components/runcards/recommendation-panel.tsx new file mode 100644 index 00000000..8eb62d7a --- /dev/null +++ b/src/components/runcards/recommendation-panel.tsx @@ -0,0 +1,249 @@ +import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon, ClipboardListIcon, MoveRightIcon, RefreshCwIcon } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { TouchableOpacity } from 'react-native'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { HStack } from '@/components/ui/hstack'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { dispatchModeKey, hasRecommendationContent, personnelDetailParts, selectionReasonKey, shortfallReasonKey, unitDetailParts } from '@/lib/run-cards'; +import { type DispatchRecommendationResultData } from '@/models/v4/runcards/dispatchRecommendationResultData'; + +interface RecommendationPanelProps { + recommendation: DispatchRecommendationResultData | null; + isLoading: boolean; + error: string | null; + /** True once a fetch settled — distinguishes "not asked yet" from "asked, nothing matched". */ + hasFetched: boolean; + /** Set once the dispatcher applies the recommendation, so the button reflects it. */ + isApplied: boolean; + onApply: () => void; + onRefresh: () => void; + testID?: string; +} + +/** + * Explainability panel for a run card recommendation. + * + * Deliberately advisory: applying it only pre-checks the dispatch selection the dispatcher was + * going to make by hand. Nothing here dispatches anything, so a wrong recommendation costs a click + * to undo rather than an unwanted response. + * + * The caller is responsible for gating this behind the `Dispatch.RunCards` feature flag — the panel + * renders nothing on its own when there is no recommendation, but it should not even be mounted for + * a department that does not use run cards. + */ +export const RecommendationPanel: React.FC = ({ recommendation, isLoading, error, hasFetched, isApplied, onApply, onRefresh, testID = 'run-card-recommendation-panel' }) => { + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === 'dark'; + const [isExpanded, setIsExpanded] = useState(true); + + const toggleExpanded = useCallback(() => setIsExpanded((previous) => !previous), []); + + const hasContent = useMemo(() => hasRecommendationContent(recommendation), [recommendation]); + const unitCount = recommendation?.Units?.length ?? 0; + const personnelCount = recommendation?.Personnel?.length ?? 0; + const canApply = unitCount > 0 || personnelCount > 0; + + const cardClass = `mb-4 rounded-lg border p-4 ${isDark ? 'border-neutral-800 bg-neutral-900' : 'border-neutral-200 bg-white'}`; + + if (isLoading) { + return ( + + + + {t('run_cards.checking')} + + + ); + } + + if (error) { + // A failed lookup must not read as "no run card applies" — that would quietly hide a response + // plan the department depends on. It also must not block the manual flow, hence the soft tone. + return ( + + + {t('run_cards.lookup_failed')} + + + + {t('common.retry')} + + + + + ); + } + + // Nothing asked for yet, or a department that has run cards on but none matching this call: stay + // out of the way entirely rather than showing an empty box on every new call. + if (!hasFetched || !hasContent || !recommendation) { + return null; + } + + return ( + + + + + + + {recommendation.MatchedRunCardName || t('run_cards.title')} + + {t('run_cards.summary', { + units: unitCount, + personnel: personnelCount, + })} + + + + {isExpanded ? : } + + + + {isExpanded ? ( + + + + {t(dispatchModeKey(recommendation.ModeUsed))} + + {recommendation.AlarmLevel > 1 ? ( + + {t('run_cards.alarm_level', { level: recommendation.AlarmLevel })} + + ) : null} + {recommendation.AutoDispatch ? ( + + {t('run_cards.auto_dispatch')} + + ) : null} + + + {/* Auto-dispatch happens server-side at call creation; saying so avoids the dispatcher + wondering why applying is optional. */} + {recommendation.AutoDispatch ? {t('run_cards.auto_dispatch_explainer')} : null} + + {unitCount > 0 ? ( + + {t('run_cards.units_section', { count: unitCount })} + {recommendation.Units.map((unit) => { + const details = unitDetailParts(unit); + return ( + + + + {unit.UnitName} + {details.length > 0 ? {details.join(' · ')} : null} + + + {t(selectionReasonKey(unit.SelectionReason))} + {unit.LocationIsStale ? {t('run_cards.stale_location')} : null} + + + + ); + })} + + ) : null} + + {personnelCount > 0 ? ( + + {t('run_cards.personnel_section', { count: personnelCount })} + {recommendation.Personnel.map((person) => { + const details = personnelDetailParts(person); + return ( + + + + {person.Name} + {details.length > 0 ? {details.join(' · ')} : null} + + + {t(selectionReasonKey(person.SelectionReason))} + {person.LocationIsStale ? {t('run_cards.stale_location')} : null} + + + + ); + })} + + ) : null} + + {/* Shortfalls are the whole point of showing this panel on a bad day: the card asked for + three engines and the engine could only find one. */} + {recommendation.Shortfalls?.length > 0 ? ( + + + + {t('run_cards.shortfalls_section')} + + {recommendation.Shortfalls.map((shortfall) => ( + + {t('run_cards.shortfall_line', { + name: shortfall.TypeOrRoleName, + filled: shortfall.FilledCount, + required: shortfall.RequiredCount, + reason: t(shortfallReasonKey(shortfall.Reason)), + })} + + ))} + + ) : null} + + {recommendation.MoveUps?.length > 0 ? ( + + + + {t('run_cards.move_ups_section')} + + {/* Advisory only — move-ups are never dispatched by the engine. */} + {recommendation.MoveUps.map((moveUp, index) => ( + + {t('run_cards.move_up_line', { + station: moveUp.StationGroupName, + resource: moveUp.SuggestedUnitName || moveUp.SuggestedUserName || t('run_cards.move_up_no_donor'), + available: moveUp.AvailableAfterDispatch, + minimum: moveUp.MinimumRequired, + })} + + ))} + + ) : null} + + {recommendation.Notes?.length > 0 ? ( + + {t('run_cards.notes_section')} + {recommendation.Notes.map((note, index) => ( + + {note} + + ))} + + ) : null} + + {/* Refresh stays put even when there is nothing to apply: a shortfall-only recommendation + is the one most worth re-running, once a unit clears or a crew signs on. */} + + {canApply ? ( + + ) : null} + + + + + + ) : null} + + ); +}; diff --git a/src/components/runcards/use-call-recommendation.ts b/src/components/runcards/use-call-recommendation.ts new file mode 100644 index 00000000..210f2a8e --- /dev/null +++ b/src/components/runcards/use-call-recommendation.ts @@ -0,0 +1,127 @@ +import { useCallback, useEffect, useMemo } from 'react'; + +import { recommendedUnitIds, recommendedUserIds } from '@/lib/run-cards'; +import { type DispatchSelection } from '@/stores/dispatch/store'; +import { useIsRunCardsEnabled } from '@/stores/feature-flags/store'; +import { useRunCardsStore } from '@/stores/runcards/store'; + +interface CallPriorityOption { + Id: number; + Name: string; +} + +interface UseCallRecommendationArgs { + /** Priority *name* as held by the form; resolved to its id against `callPriorities`. */ + priorityName: string | null | undefined; + /** Call type name — the API resolves it case-insensitively. */ + typeName: string | null | undefined; + latitude: number | null | undefined; + longitude: number | null | undefined; + callPriorities: CallPriorityOption[]; + /** Alarm level to preview. 1 for a new call; the next level when previewing an escalation. */ + alarmLevel?: number; +} + +/** + * Keeps a run card recommendation in step with the call being composed. + * + * Fetching is debounced in the store because priority, type and location all change while the + * dispatcher works, and every change is a server round trip that runs the whole selection engine. + * + * Does nothing at all when `Dispatch.RunCards` is off for the department — no request, no state, + * no panel. That is the single gate for the whole feature on this screen. + */ +export const useCallRecommendation = ({ priorityName, typeName, latitude, longitude, callPriorities, alarmLevel = 1 }: UseCallRecommendationArgs) => { + const isRunCardsEnabled = useIsRunCardsEnabled(); + + const recommendation = useRunCardsStore((state) => state.recommendation); + const isLoading = useRunCardsStore((state) => state.isLoading); + const error = useRunCardsStore((state) => state.error); + const hasFetched = useRunCardsStore((state) => state.hasFetched); + const appliedRunCardId = useRunCardsStore((state) => state.appliedRunCardId); + const fetchRecommendationDebounced = useRunCardsStore((state) => state.fetchRecommendationDebounced); + const fetchRecommendation = useRunCardsStore((state) => state.fetchRecommendation); + const markApplied = useRunCardsStore((state) => state.markApplied); + const clear = useRunCardsStore((state) => state.clear); + + const priorityId = useMemo(() => { + if (!priorityName) { + return null; + } + return callPriorities.find((priority) => priority.Name === priorityName)?.Id ?? null; + }, [priorityName, callPriorities]); + + const request = useMemo( + () => ({ + priority: priorityId ?? -1, + type: typeName ?? '', + latitude: typeof latitude === 'number' ? latitude : null, + longitude: typeof longitude === 'number' ? longitude : null, + alarmLevel, + }), + [priorityId, typeName, latitude, longitude, alarmLevel] + ); + + const canRequest = isRunCardsEnabled && priorityId !== null && !!typeName; + + useEffect(() => { + // Dropping the priority or type mid-debounce has to cancel the pending lookup as well as drop + // what is on screen. Returning early would let the timer fire with the inputs the dispatcher + // just removed, and leave a recommendation matching a call that no longer exists. + if (!canRequest) { + clear(); + return; + } + + // A new request supersedes any pending timer and any in-flight lookup; the store handles both. + fetchRecommendationDebounced(request); + }, [canRequest, request, fetchRecommendationDebounced, clear]); + + // Leaving the screen must not strand a recommendation for the next call composed. + useEffect(() => () => clear(), [clear]); + + const refresh = useCallback(() => { + if (!canRequest) { + return; + } + void fetchRecommendation(request); + }, [canRequest, fetchRecommendation, request]); + + /** + * Merges the recommendation into an existing dispatch selection. Additive on purpose: anyone the + * dispatcher already picked stays picked, and applying twice is idempotent. Clears `everyone`, + * which is mutually exclusive with an explicit selection. + */ + const applyToSelection = useCallback( + (current: DispatchSelection): DispatchSelection => { + if (!recommendation) { + return current; + } + + const unitIds = new Set([...current.units, ...recommendedUnitIds(recommendation)]); + const userIds = new Set([...current.users, ...recommendedUserIds(recommendation)]); + + markApplied(recommendation.MatchedRunCardId ?? null); + + return { + ...current, + everyone: false, + units: Array.from(unitIds), + users: Array.from(userIds), + }; + }, + [recommendation, markApplied] + ); + + return { + /** False when the department has run cards off — callers should not render the panel at all. */ + isRunCardsEnabled, + recommendation, + isLoading, + error, + hasFetched, + isApplied: !!recommendation?.MatchedRunCardId && appliedRunCardId === recommendation.MatchedRunCardId, + refresh, + applyToSelection, + }; +}; diff --git a/src/components/settings/language-item.tsx b/src/components/settings/language-item.tsx index 2ec7f410..593aa771 100644 --- a/src/components/settings/language-item.tsx +++ b/src/components/settings/language-item.tsx @@ -26,6 +26,7 @@ export const LanguageItem = () => { { label: translate('settings.spanish'), value: 'es' }, { label: translate('settings.swedish'), value: 'sv' }, { label: translate('settings.german'), value: 'de' }, + { label: translate('settings.greek'), value: 'el' }, { label: translate('settings.french'), value: 'fr' }, { label: translate('settings.italian'), value: 'it' }, { label: translate('settings.polish'), value: 'pl' }, diff --git a/src/hooks/__tests__/use-new-call-field-policy.test.ts b/src/hooks/__tests__/use-new-call-field-policy.test.ts new file mode 100644 index 00000000..11941f12 --- /dev/null +++ b/src/hooks/__tests__/use-new-call-field-policy.test.ts @@ -0,0 +1,97 @@ +import { renderHook, waitFor } from '@testing-library/react-native'; + +import { getNewCallFieldPolicy } from '@/api/calls/newCallFieldPolicy'; +import { useNewCallFieldPolicy } from '@/hooks/use-new-call-field-policy'; +import { NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData'; + +jest.mock('@/api/calls/newCallFieldPolicy', () => ({ + getNewCallFieldPolicy: jest.fn(), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn() }, +})); + +const mockedGetPolicy = getNewCallFieldPolicy as jest.Mock; + +const renderPolicy = async (rules: { Key: string; Visible: boolean; Required: boolean }[]) => { + mockedGetPolicy.mockResolvedValue({ Rules: rules }); + + const { result } = renderHook(() => useNewCallFieldPolicy()); + await waitFor(() => expect(result.current.isLoaded).toBe(true)); + + return result; +}; + +describe('useNewCallFieldPolicy', () => { + beforeEach(() => jest.clearAllMocks()); + + it('shows everything and requires nothing for an unconfigured department', async () => { + const result = await renderPolicy([]); + + expect(result.current.isVisible(NewCallFieldKeys.ContactInfo)).toBe(true); + expect(result.current.isRequired(NewCallFieldKeys.ContactInfo)).toBe(false); + expect(result.current.missingRequired({})).toEqual([]); + }); + + it('hides fields the department turned off', async () => { + const result = await renderPolicy([{ Key: NewCallFieldKeys.IncidentId, Visible: false, Required: false }]); + + expect(result.current.isVisible(NewCallFieldKeys.IncidentId)).toBe(false); + // Untouched fields keep the default. + expect(result.current.isVisible(NewCallFieldKeys.Address)).toBe(true); + }); + + it('reports required fields that are blank', async () => { + const result = await renderPolicy([ + { Key: NewCallFieldKeys.Address, Visible: true, Required: true }, + { Key: NewCallFieldKeys.ContactInfo, Visible: true, Required: true }, + ]); + + expect(result.current.missingRequired({ [NewCallFieldKeys.Address]: 'Nieuwstraat 14' })).toEqual([NewCallFieldKeys.ContactInfo]); + }); + + it('treats whitespace and empty collections as missing', async () => { + const result = await renderPolicy([ + { Key: NewCallFieldKeys.Note, Visible: true, Required: true }, + { Key: NewCallFieldKeys.Protocols, Visible: true, Required: true }, + ]); + + const missing = result.current.missingRequired({ + [NewCallFieldKeys.Note]: ' ', + [NewCallFieldKeys.Protocols]: [], + }); + + expect(missing).toEqual([NewCallFieldKeys.Note, NewCallFieldKeys.Protocols]); + }); + + it('treats an unselected dispatch list as missing', async () => { + // The new-call screens collapse "anyone selected?" to a boolean, so false has to read as blank + // rather than as a filled-in field. + const result = await renderPolicy([{ Key: NewCallFieldKeys.DispatchList, Visible: true, Required: true }]); + + expect(result.current.missingRequired({ [NewCallFieldKeys.DispatchList]: false })).toEqual([NewCallFieldKeys.DispatchList]); + expect(result.current.missingRequired({ [NewCallFieldKeys.DispatchList]: true })).toEqual([]); + }); + + it('never requires a hidden field', async () => { + // Requiring something nobody can fill in would make call creation impossible; the server takes + // the same stance, so the two cannot disagree. + const result = await renderPolicy([{ Key: NewCallFieldKeys.Address, Visible: false, Required: true }]); + + expect(result.current.isRequired(NewCallFieldKeys.Address)).toBe(false); + expect(result.current.missingRequired({})).toEqual([]); + }); + + it('falls open when the policy cannot be loaded', async () => { + // Hiding fields a dispatcher needs is far worse than showing one they were told to hide, and the + // server enforces the real policy on save regardless. + mockedGetPolicy.mockRejectedValue(new Error('offline')); + + const { result } = renderHook(() => useNewCallFieldPolicy()); + await waitFor(() => expect(result.current.isLoaded).toBe(true)); + + expect(result.current.isVisible(NewCallFieldKeys.ContactInfo)).toBe(true); + expect(result.current.missingRequired({})).toEqual([]); + }); +}); diff --git a/src/hooks/use-new-call-field-policy.ts b/src/hooks/use-new-call-field-policy.ts new file mode 100644 index 00000000..55de4387 --- /dev/null +++ b/src/hooks/use-new-call-field-policy.ts @@ -0,0 +1,143 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { getNewCallFieldPolicy } from '@/api/calls/newCallFieldPolicy'; +import { logger } from '@/lib/logging'; +import { type NewCallFieldKey, NewCallFieldKeys, type NewCallFieldRuleData } from '@/models/v4/calls/newCallFieldPolicyResultData'; + +/** + * Applies the department's new-call field policy to a call form. + * + * Two jobs: hide the fields the department does not use, and refuse to submit until the fields it + * marked required have values. The point, in the words of the department that asked for it, is that + * a call-taker should not be able to forward an incident to the field until the crews have what + * they need. + * + * Fail-open by design: an unreachable or failed policy lookup leaves the stock form in place. The + * server enforces the same policy on save, so a client that guessed wrong gets a clear rejection + * rather than quietly creating an incomplete call. + */ + +export interface NewCallFieldPolicy { + /** True when the field should be rendered at all. */ + isVisible: (key: NewCallFieldKey) => boolean; + /** True when the field must have a value before the call can be created. */ + isRequired: (key: NewCallFieldKey) => boolean; + /** + * Required fields left blank, given the current form values. Empty means the form may submit. + * Keys are the same stable strings the server uses, so the caller can map them to its own inputs. + */ + missingRequired: (values: Partial>) => NewCallFieldKey[]; + isLoaded: boolean; +} + +/** Lowercased key -> canonical key, so a stored rule's casing never leaks out to callers. */ +const CANONICAL_KEYS = new Map(Object.values(NewCallFieldKeys).map((key) => [key.toLowerCase(), key])); + +const hasValue = (value: unknown): boolean => { + if (value === null || value === undefined) { + return false; + } + + if (typeof value === 'string') { + return value.trim().length > 0; + } + + if (typeof value === 'number') { + return Number.isFinite(value) && value !== 0; + } + + // Callers answer "is this filled in?" with a boolean for fields that are a selection rather than + // a value — an empty dispatch list arrives as false, and that is a blank field, not a filled one. + if (typeof value === 'boolean') { + return value; + } + + if (Array.isArray(value)) { + return value.length > 0; + } + + return true; +}; + +export const useNewCallFieldPolicy = (): NewCallFieldPolicy => { + const [rules, setRules] = useState([]); + const [isLoaded, setIsLoaded] = useState(false); + + useEffect(() => { + let cancelled = false; + + getNewCallFieldPolicy() + .then((policy) => { + if (!cancelled) { + setRules(policy?.Rules ?? []); + setIsLoaded(true); + } + }) + .catch((error) => { + // Fail open: keep the stock form rather than hiding fields the dispatcher may need. + logger.error({ message: 'Failed to load the new call field policy', context: { error } }); + if (!cancelled) { + setRules([]); + setIsLoaded(true); + } + }); + + return () => { + cancelled = true; + }; + }, []); + + const rulesByKey = useMemo(() => { + const map = new Map(); + + for (const rule of rules) { + if (rule?.Key) { + map.set(rule.Key.toLowerCase(), rule); + } + } + + return map; + }, [rules]); + + const isVisible = useCallback((key: NewCallFieldKey) => rulesByKey.get(key.toLowerCase())?.Visible ?? true, [rulesByKey]); + + const isRequired = useCallback( + (key: NewCallFieldKey) => { + const rule = rulesByKey.get(key.toLowerCase()); + + // A hidden field is never required — requiring something nobody can fill in would make call + // creation impossible. The server takes the same stance. + return !!rule && rule.Visible && rule.Required; + }, + [rulesByKey] + ); + + const missingRequired = useCallback( + (values: Partial>) => { + const missing: NewCallFieldKey[] = []; + + for (const rule of rules) { + if (!rule?.Key || !rule.Visible || !rule.Required) { + continue; + } + + // Resolve back to the canonical key rather than returning the lowercased comparison form: + // callers map these onto their own inputs, and 'contactinfo' would match nothing. + const key = CANONICAL_KEYS.get(rule.Key.toLowerCase()); + + if (!key) { + continue; + } + + if (!hasValue(values[key])) { + missing.push(key); + } + } + + return missing; + }, + [rules] + ); + + return { isVisible, isRequired, missingRequired, isLoaded }; +}; diff --git a/src/lib/__tests__/map-center.test.ts b/src/lib/__tests__/map-center.test.ts new file mode 100644 index 00000000..ed4cd547 --- /dev/null +++ b/src/lib/__tests__/map-center.test.ts @@ -0,0 +1,93 @@ +import { FALLBACK_MAP_CENTER, getDepartmentMapCenter } from '@/lib/map-center'; +import { useCoreStore } from '@/stores/app/core-store'; + +jest.mock('@/stores/app/core-store', () => ({ + useCoreStore: { getState: jest.fn() }, +})); + +const mockedGetState = useCoreStore.getState as unknown as jest.Mock; + +const withConfig = (config: Record | null) => { + mockedGetState.mockReturnValue({ config }); +}; + +describe('getDepartmentMapCenter', () => { + beforeEach(() => jest.clearAllMocks()); + + it('uses the department centre the server resolved', () => { + withConfig({ MapCenterLatitude: 50.8698, MapCenterLongitude: 3.8102, MapCenterZoomLevel: 12 }); + + expect(getDepartmentMapCenter()).toEqual({ latitude: 50.8698, longitude: 3.8102, zoomLevel: 12 }); + }); + + it('falls back while config has not loaded yet', () => { + withConfig(null); + + expect(getDepartmentMapCenter()).toEqual(FALLBACK_MAP_CENTER); + }); + + it('treats 0,0 as unset rather than dropping the user in the Atlantic', () => { + // Null Island is the shape of a missing value, never a real department. + withConfig({ MapCenterLatitude: 0, MapCenterLongitude: 0, MapCenterZoomLevel: 9 }); + + expect(getDepartmentMapCenter()).toEqual(FALLBACK_MAP_CENTER); + }); + + it('rejects a half-populated centre', () => { + withConfig({ MapCenterLatitude: 50.8698, MapCenterLongitude: null, MapCenterZoomLevel: 9 }); + + expect(getDepartmentMapCenter()).toEqual(FALLBACK_MAP_CENTER); + }); + + it('defaults the zoom when the department has not set one', () => { + withConfig({ MapCenterLatitude: 50.8698, MapCenterLongitude: 3.8102, MapCenterZoomLevel: 0 }); + + expect(getDepartmentMapCenter().zoomLevel).toBe(FALLBACK_MAP_CENTER.zoomLevel); + }); + + it('rejects non-finite coordinates', () => { + withConfig({ MapCenterLatitude: Number.NaN, MapCenterLongitude: 3.8102, MapCenterZoomLevel: 9 }); + + expect(getDepartmentMapCenter()).toEqual(FALLBACK_MAP_CENTER); + }); + + it('keeps a zero on a single axis', () => { + // The equator and the prime meridian are real places, not the shape of an unset field. Only the + // 0,0 pair is treated as missing. + withConfig({ MapCenterLatitude: 0, MapCenterLongitude: -0.1278, MapCenterZoomLevel: 11 }); + expect(getDepartmentMapCenter()).toEqual({ latitude: 0, longitude: -0.1278, zoomLevel: 11 }); + + withConfig({ MapCenterLatitude: 5.6037, MapCenterLongitude: 0, MapCenterZoomLevel: 11 }); + expect(getDepartmentMapCenter()).toEqual({ latitude: 5.6037, longitude: 0, zoomLevel: 11 }); + }); + + it('rejects out-of-range coordinates', () => { + const cases = [ + { MapCenterLatitude: 91, MapCenterLongitude: 4.3517 }, + { MapCenterLatitude: -90.5, MapCenterLongitude: 4.3517 }, + { MapCenterLatitude: 50.8503, MapCenterLongitude: 181 }, + { MapCenterLatitude: 50.8503, MapCenterLongitude: -180.1 }, + ]; + + for (const center of cases) { + withConfig({ ...center, MapCenterZoomLevel: 11 }); + + expect(getDepartmentMapCenter()).toEqual(FALLBACK_MAP_CENTER); + } + }); + + it('accepts the range boundaries', () => { + withConfig({ MapCenterLatitude: -90, MapCenterLongitude: 180, MapCenterZoomLevel: 3 }); + + expect(getDepartmentMapCenter()).toEqual({ latitude: -90, longitude: 180, zoomLevel: 3 }); + }); + + it('rejects a non-finite zoom while keeping the coordinates', () => { + // Infinity is greater than zero, so the positivity test alone would let it reach the camera. + for (const zoom of [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NaN]) { + withConfig({ MapCenterLatitude: 50.8503, MapCenterLongitude: 4.3517, MapCenterZoomLevel: zoom }); + + expect(getDepartmentMapCenter()).toEqual({ latitude: 50.8503, longitude: 4.3517, zoomLevel: FALLBACK_MAP_CENTER.zoomLevel }); + } + }); +}); diff --git a/src/lib/__tests__/run-cards.test.ts b/src/lib/__tests__/run-cards.test.ts new file mode 100644 index 00000000..bcb6ee9c --- /dev/null +++ b/src/lib/__tests__/run-cards.test.ts @@ -0,0 +1,201 @@ +import { + dispatchModeKey, + formatDistance, + formatEta, + hasRecommendationContent, + personnelDetailParts, + recommendedUnitIds, + recommendedUserIds, + selectionReasonKey, + shortfallReasonKey, + unitDetailParts, +} from '@/lib/run-cards'; +import { + DispatchRecommendationMode, + type DispatchRecommendationResultData, + type PersonnelRecommendationData, + RecommendationSelectionReason, + RequirementShortfallReason, + type UnitRecommendationData, +} from '@/models/v4/runcards/dispatchRecommendationResultData'; + +const baseRecommendation = (overrides: Partial = {}): DispatchRecommendationResultData => ({ + MatchedRunCardId: 7, + MatchedRunCardName: 'Structure Fire', + AlarmLevel: 1, + ModeUsed: DispatchRecommendationMode.StationBased, + AutoDispatch: false, + Units: [], + Personnel: [], + Shortfalls: [], + MoveUps: [], + Notes: [], + ...overrides, +}); + +const unit = (overrides: Partial = {}): UnitRecommendationData => ({ + UnitId: 1, + UnitName: 'Engine 1', + UnitTypeId: 2, + UnitTypeName: 'Engine', + StationGroupId: 3, + StationGroupName: 'Station 3', + SelectionReason: RecommendationSelectionReason.InGeofence, + CascadeDepth: 0, + DistanceMeters: null, + EtaSeconds: null, + LocationTimestamp: null, + LocationIsStale: false, + CurrentStatusText: null, + StaffingLevel: null, + SatisfiesRequirementId: 11, + ...overrides, +}); + +const person = (overrides: Partial = {}): PersonnelRecommendationData => ({ + UserId: 'user-1', + Name: 'Jane Doe', + RoleId: 4, + RoleName: 'Paramedic', + StationGroupId: 3, + StationGroupName: 'Station 3', + SelectionReason: RecommendationSelectionReason.ClosestByEta, + CascadeDepth: 0, + DistanceMeters: null, + EtaSeconds: null, + LocationTimestamp: null, + LocationIsStale: false, + CurrentStatusText: null, + SatisfiesRequirementId: 12, + ...overrides, +}); + +describe('formatDistance', () => { + it('uses metres under a kilometre and kilometres above', () => { + expect(formatDistance(420)).toBe('420 m'); + expect(formatDistance(999)).toBe('999 m'); + expect(formatDistance(1000)).toBe('1.0 km'); + expect(formatDistance(4321)).toBe('4.3 km'); + }); + + it('returns null when the engine had no distance to report', () => { + // Station-based mode does not always compute a distance, and rendering "null m" would be worse + // than rendering nothing. + expect(formatDistance(null)).toBeNull(); + expect(formatDistance(undefined)).toBeNull(); + expect(formatDistance(-1)).toBeNull(); + expect(formatDistance(Number.NaN)).toBeNull(); + }); +}); + +describe('formatEta', () => { + it('rounds up to whole minutes above a minute', () => { + expect(formatEta(45)).toBe('45s'); + expect(formatEta(61)).toBe('2 min'); + expect(formatEta(600)).toBe('10 min'); + }); + + it('returns null when no routed ETA was computed', () => { + expect(formatEta(null)).toBeNull(); + expect(formatEta(undefined)).toBeNull(); + }); +}); + +describe('key mapping', () => { + it('maps every selection reason to its own key', () => { + const keys = [ + RecommendationSelectionReason.InGeofence, + RecommendationSelectionReason.CascadeStation, + RecommendationSelectionReason.ClosestByDistance, + RecommendationSelectionReason.ClosestByEta, + RecommendationSelectionReason.RestPeriodOverridden, + ].map(selectionReasonKey); + + expect(new Set(keys).size).toBe(keys.length); + expect(selectionReasonKey(RecommendationSelectionReason.Unknown)).toBe('run_cards.reason.unknown'); + }); + + it('maps every shortfall reason to its own key', () => { + const keys = [ + RequirementShortfallReason.NoCandidatesAvailable, + RequirementShortfallReason.OutsideRadius, + RequirementShortfallReason.LocationsTooStale, + RequirementShortfallReason.NoLocationData, + RequirementShortfallReason.UnitsNotStaffed, + RequirementShortfallReason.AllInRestPeriod, + RequirementShortfallReason.StationsExhausted, + ].map(shortfallReasonKey); + + expect(new Set(keys).size).toBe(keys.length); + expect(shortfallReasonKey(RequirementShortfallReason.Unknown)).toBe('run_cards.shortfall.unknown'); + }); + + it('maps dispatch modes, defaulting to manual', () => { + expect(dispatchModeKey(DispatchRecommendationMode.StationBased)).toBe('run_cards.mode.station_based'); + expect(dispatchModeKey(DispatchRecommendationMode.ClosestUnit)).toBe('run_cards.mode.closest_unit'); + expect(dispatchModeKey(DispatchRecommendationMode.ManualOnly)).toBe('run_cards.mode.manual_only'); + }); +}); + +describe('hasRecommendationContent', () => { + it('is false with no recommendation or no matched card', () => { + expect(hasRecommendationContent(null)).toBe(false); + expect(hasRecommendationContent(baseRecommendation({ MatchedRunCardId: null }))).toBe(false); + }); + + it('is false for a matched card that selected nothing and reported nothing', () => { + // Manual-only mode with no shortfalls has nothing to tell the dispatcher. + expect(hasRecommendationContent(baseRecommendation())).toBe(false); + }); + + it('is true when there is anything to show', () => { + expect(hasRecommendationContent(baseRecommendation({ Units: [unit()] }))).toBe(true); + expect(hasRecommendationContent(baseRecommendation({ Personnel: [person()] }))).toBe(true); + expect( + hasRecommendationContent( + baseRecommendation({ + Shortfalls: [ + { + IsUnitRequirement: true, + RequirementId: 1, + TypeOrRoleId: 2, + TypeOrRoleName: 'Engine', + AlarmLevel: 1, + RequiredCount: 3, + FilledCount: 1, + Reason: RequirementShortfallReason.NoCandidatesAvailable, + }, + ], + }) + ) + ).toBe(true); + }); +}); + +describe('recommended id extraction', () => { + it('returns unit ids as strings so they match the dispatch selection', () => { + expect(recommendedUnitIds(baseRecommendation({ Units: [unit({ UnitId: 4 }), unit({ UnitId: 9 })] }))).toEqual(['4', '9']); + }); + + it('drops personnel rows with no user id', () => { + const withBlank = baseRecommendation({ Personnel: [person({ UserId: 'a' }), person({ UserId: '' })] }); + expect(recommendedUserIds(withBlank)).toEqual(['a']); + }); + + it('handles a null recommendation', () => { + expect(recommendedUnitIds(null)).toEqual([]); + expect(recommendedUserIds(null)).toEqual([]); + }); +}); + +describe('detail lines', () => { + it('includes only the facts the engine actually knew', () => { + expect(unitDetailParts(unit({ StationGroupName: 'Station 3', DistanceMeters: 1500, EtaSeconds: 200, CurrentStatusText: 'Available' }))).toEqual(['Station 3', '1.5 km', '4 min', 'Available']); + + expect(unitDetailParts(unit({ StationGroupName: null, DistanceMeters: null, EtaSeconds: null, CurrentStatusText: null }))).toEqual([]); + }); + + it('leads with the role for personnel', () => { + expect(personnelDetailParts(person({ RoleName: 'Paramedic', StationGroupName: 'Station 3', DistanceMeters: 300 }))).toEqual(['Paramedic', 'Station 3', '300 m']); + }); +}); diff --git a/src/lib/cache/cache-manager.ts b/src/lib/cache/cache-manager.ts index e6690351..a63c21f7 100644 --- a/src/lib/cache/cache-manager.ts +++ b/src/lib/cache/cache-manager.ts @@ -1,5 +1,7 @@ import { storage } from '@/lib/storage'; +import { getCacheScopeKey } from './cache-scope'; + interface CacheItem { data: T; timestamp: number; @@ -21,7 +23,9 @@ export class CacheManager { private getCacheKey(endpoint: string, params?: Record): string { const queryString = params ? `?${new URLSearchParams(params as Record)}` : ''; - return `api_cache_${endpoint}${queryString}`; + // Scope by the signed-in identity so a second user (or a department switch) on the same device + // is never served the previous account's rosters, units or contacts out of MMKV. + return `api_cache_${getCacheScopeKey()}_${endpoint}${queryString}`; } private isExpired(timestamp: number, expiresIn: number): boolean { diff --git a/src/lib/cache/cache-scope.ts b/src/lib/cache/cache-scope.ts new file mode 100644 index 00000000..d8703225 --- /dev/null +++ b/src/lib/cache/cache-scope.ts @@ -0,0 +1,82 @@ +import { storage } from '@/lib/storage'; + +/** + * Identity the API cache is scoped to. + * + * Cache keys used to be built from the server URL and endpoint alone. On a shared device that meant + * signing out and signing back in as someone else served the previous account's units, personnel and + * contacts straight out of MMKV, and a user moving between departments kept the old department's + * data. Both are fixed by making the identity part of the key. + * + * This lives in its own leaf module (storage is its only import) so the api client can read it + * without importing the auth store, which imports the api client. + */ +export interface CacheScope { + userId: string | null; + departmentId: string | null; +} + +const CACHE_SCOPE_KEY = 'api_cache_scope'; + +let cachedScope: CacheScope | null = null; + +const readScope = (): CacheScope => { + if (cachedScope) { + return cachedScope; + } + + try { + const raw = storage.getString(CACHE_SCOPE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as Partial; + cachedScope = { + userId: typeof parsed.userId === 'string' ? parsed.userId : null, + departmentId: typeof parsed.departmentId === 'string' ? parsed.departmentId : null, + }; + return cachedScope; + } + } catch { + // A corrupt scope must not break every cached request; fall through to anonymous. + } + + cachedScope = { userId: null, departmentId: null }; + return cachedScope; +}; + +export const getCacheScope = (): CacheScope => readScope(); + +/** + * Records who the cache belongs to. Call on login and whenever the department is resolved or + * changed. Values are merged, so learning the department later does not erase the user. + */ +export const setCacheScope = (scope: Partial): void => { + const current = readScope(); + const next: CacheScope = { + userId: scope.userId !== undefined ? scope.userId : current.userId, + departmentId: scope.departmentId !== undefined ? scope.departmentId : current.departmentId, + }; + + cachedScope = next; + + try { + storage.set(CACHE_SCOPE_KEY, JSON.stringify(next)); + } catch { + // In-memory scope is still correct for this session. + } +}; + +export const clearCacheScope = (): void => { + cachedScope = { userId: null, departmentId: null }; + + try { + storage.delete(CACHE_SCOPE_KEY); + } catch { + // Nothing to do — the in-memory scope is already reset. + } +}; + +/** Key fragment identifying the current scope. */ +export const getCacheScopeKey = (): string => { + const scope = readScope(); + return `${scope.departmentId ?? 'nodept'}_${scope.userId ?? 'anon'}`; +}; diff --git a/src/lib/i18n/resources.ts b/src/lib/i18n/resources.ts index 48827b62..810349f7 100644 --- a/src/lib/i18n/resources.ts +++ b/src/lib/i18n/resources.ts @@ -1,5 +1,6 @@ import ar from '@/translations/ar.json'; import de from '@/translations/de.json'; +import el from '@/translations/el.json'; import en from '@/translations/en.json'; import es from '@/translations/es.json'; import fr from '@/translations/fr.json'; @@ -36,6 +37,9 @@ export const resources = { ar: { translation: ar, }, + el: { + translation: el, + }, }; export type Language = keyof typeof resources; diff --git a/src/lib/map-center.ts b/src/lib/map-center.ts new file mode 100644 index 00000000..a462fe3e --- /dev/null +++ b/src/lib/map-center.ts @@ -0,0 +1,69 @@ +import { useCoreStore } from '@/stores/app/core-store'; + +/** + * The department's default map center. + * + * Every map surface — new-call location pickers, live maps, board maps — should open here when it + * has nothing better (no call location, no device fix). Before this existed each app fell back to + * its own hardcoded coordinates, which is how a Belgian department ended up looking at a map of + * Nevada, and an Australian sample coordinate ended up shipping as the web fallback. + * + * The server always populates these: it resolves the department's configured center, falls back to + * geocoding the department address, and finally to a system default. The guard here is only for the + * window before config has loaded. + */ + +export interface MapCenter { + latitude: number; + longitude: number; + zoomLevel: number; +} + +/** + * Used only until config arrives. Deliberately the same value the server falls back to, so a map + * that renders during bootstrap does not visibly jump somewhere else a moment later. + */ +export const FALLBACK_MAP_CENTER: MapCenter = { + latitude: 39.14086268299356, + longitude: -119.7583809782715, + zoomLevel: 9, +}; + +const isUsableCoordinate = (value: number | null | undefined, limit: number): value is number => typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= limit; + +const toMapCenter = (latitude: number | null | undefined, longitude: number | null | undefined, zoomLevel: number | null | undefined): MapCenter => { + // Out of range is corrupt rather than merely absent, and handing it to the map is worse than + // showing the fallback. + if (!isUsableCoordinate(latitude, 90) || !isUsableCoordinate(longitude, 180)) { + return FALLBACK_MAP_CENTER; + } + + // 0,0 is Null Island — the shape of an unset value rather than a real department, so treat it as + // missing instead of dropping the user in the Atlantic. Only the pair: a lone zero is an ordinary + // coordinate on the equator or the prime meridian, and rejecting those moved real departments + // (Ghana, Ecuador, most of the UK) to the other side of the world. + if (latitude === 0 && longitude === 0) { + return FALLBACK_MAP_CENTER; + } + + return { + latitude, + longitude, + // Infinity is greater than zero, so the positivity test alone would let it reach the camera. + zoomLevel: typeof zoomLevel === 'number' && Number.isFinite(zoomLevel) && zoomLevel > 0 ? zoomLevel : FALLBACK_MAP_CENTER.zoomLevel, + }; +}; + +/** Reactive: re-renders when config lands. */ +export const useDepartmentMapCenter = (): MapCenter => { + const config = useCoreStore((state) => state.config); + + return toMapCenter(config?.MapCenterLatitude, config?.MapCenterLongitude, config?.MapCenterZoomLevel); +}; + +/** Non-reactive read for imperative paths (effects, camera setup, one-shot defaults). */ +export const getDepartmentMapCenter = (): MapCenter => { + const config = useCoreStore.getState().config; + + return toMapCenter(config?.MapCenterLatitude, config?.MapCenterLongitude, config?.MapCenterZoomLevel); +}; diff --git a/src/lib/map-markers-web.ts b/src/lib/map-markers-web.ts index 4f81ce7c..ba4a1b0e 100644 --- a/src/lib/map-markers-web.ts +++ b/src/lib/map-markers-web.ts @@ -159,7 +159,7 @@ export const createMapMarkerElement = (pin: MapMakerInfoData, colorScheme: 'dark iconContainer.style.height = '32px'; const iconKey = resolveMapMarkerIconKey(pin) as MapIconKey; - const iconData = MAP_ICONS[iconKey] || MAP_ICONS['call']; + const iconData = MAP_ICONS[iconKey] || MAP_ICONS['flag']; const img = document.createElement('img'); const imgSrc = getMapIconWebUrl(iconData); img.src = imgSrc; @@ -168,7 +168,7 @@ export const createMapMarkerElement = (pin: MapMakerInfoData, colorScheme: 'dark img.style.objectFit = 'contain'; img.alt = pin.Title; img.onerror = () => { - img.src = getMapIconWebUrl(MAP_ICONS['call']); + img.src = getMapIconWebUrl(MAP_ICONS['flag']); }; iconContainer.appendChild(img); el.appendChild(iconContainer); diff --git a/src/lib/run-cards.ts b/src/lib/run-cards.ts new file mode 100644 index 00000000..cb8c30d5 --- /dev/null +++ b/src/lib/run-cards.ts @@ -0,0 +1,166 @@ +import { + DispatchRecommendationMode, + type DispatchRecommendationResultData, + type PersonnelRecommendationData, + RecommendationSelectionReason, + RequirementShortfallReason, + type UnitRecommendationData, +} from '@/models/v4/runcards/dispatchRecommendationResultData'; + +/** + * Presentation helpers for run card recommendations. + * + * Kept out of the components so the label mapping and the "is this worth showing" rules are unit + * testable, and so the new-call and edit-call screens cannot drift apart on them. + */ + +/** Translation key for a selection reason, e.g. why this engine picked this unit. */ +export const selectionReasonKey = (reason: RecommendationSelectionReason): string => { + switch (reason) { + case RecommendationSelectionReason.InGeofence: + return 'run_cards.reason.in_geofence'; + case RecommendationSelectionReason.CascadeStation: + return 'run_cards.reason.cascade_station'; + case RecommendationSelectionReason.ClosestByDistance: + return 'run_cards.reason.closest_by_distance'; + case RecommendationSelectionReason.ClosestByEta: + return 'run_cards.reason.closest_by_eta'; + case RecommendationSelectionReason.RestPeriodOverridden: + return 'run_cards.reason.rest_period_overridden'; + default: + return 'run_cards.reason.unknown'; + } +}; + +/** Translation key explaining why a requirement could not be filled. */ +export const shortfallReasonKey = (reason: RequirementShortfallReason): string => { + switch (reason) { + case RequirementShortfallReason.NoCandidatesAvailable: + return 'run_cards.shortfall.no_candidates'; + case RequirementShortfallReason.OutsideRadius: + return 'run_cards.shortfall.outside_radius'; + case RequirementShortfallReason.LocationsTooStale: + return 'run_cards.shortfall.locations_stale'; + case RequirementShortfallReason.NoLocationData: + return 'run_cards.shortfall.no_location_data'; + case RequirementShortfallReason.UnitsNotStaffed: + return 'run_cards.shortfall.not_staffed'; + case RequirementShortfallReason.AllInRestPeriod: + return 'run_cards.shortfall.all_in_rest_period'; + case RequirementShortfallReason.StationsExhausted: + return 'run_cards.shortfall.stations_exhausted'; + default: + return 'run_cards.shortfall.unknown'; + } +}; + +/** Translation key for the mode the engine resolved to. */ +export const dispatchModeKey = (mode: DispatchRecommendationMode): string => { + switch (mode) { + case DispatchRecommendationMode.StationBased: + return 'run_cards.mode.station_based'; + case DispatchRecommendationMode.ClosestUnit: + return 'run_cards.mode.closest_unit'; + default: + return 'run_cards.mode.manual_only'; + } +}; + +/** Metres below 1 km, kilometres to one decimal above. Null when the engine had no distance. */ +export const formatDistance = (meters: number | null | undefined): string | null => { + if (typeof meters !== 'number' || !Number.isFinite(meters) || meters < 0) { + return null; + } + + if (meters < 1000) { + return `${Math.round(meters)} m`; + } + + return `${(meters / 1000).toFixed(1)} km`; +}; + +/** Rounded-up minutes, or seconds under a minute. Null when no routed ETA was computed. */ +export const formatEta = (seconds: number | null | undefined): string | null => { + if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) { + return null; + } + + if (seconds < 60) { + return `${Math.round(seconds)}s`; + } + + return `${Math.ceil(seconds / 60)} min`; +}; + +/** + * True when the recommendation is worth putting on screen: a card matched and it either selected + * something, could not fill something, or wants a move-up. A matched card that produced nothing at + * all (manual-only mode with no shortfalls) has nothing to say. + */ +export const hasRecommendationContent = (recommendation: DispatchRecommendationResultData | null): boolean => { + if (!recommendation || !recommendation.MatchedRunCardId) { + return false; + } + + return (recommendation.Units?.length ?? 0) > 0 || (recommendation.Personnel?.length ?? 0) > 0 || (recommendation.Shortfalls?.length ?? 0) > 0 || (recommendation.MoveUps?.length ?? 0) > 0; +}; + +/** Unit ids the recommendation selected, as the strings the dispatch selection uses. */ +export const recommendedUnitIds = (recommendation: DispatchRecommendationResultData | null): string[] => (recommendation?.Units ?? []).map((unit) => String(unit.UnitId)); + +/** User ids the recommendation selected. */ +export const recommendedUserIds = (recommendation: DispatchRecommendationResultData | null): string[] => (recommendation?.Personnel ?? []).filter((person) => !!person.UserId).map((person) => person.UserId); + +/** Secondary line for a unit row: station, distance, ETA and staleness, whichever the engine knew. */ +export const unitDetailParts = (unit: UnitRecommendationData): string[] => { + const parts: string[] = []; + + if (unit.StationGroupName) { + parts.push(unit.StationGroupName); + } + + const distance = formatDistance(unit.DistanceMeters); + if (distance) { + parts.push(distance); + } + + const eta = formatEta(unit.EtaSeconds); + if (eta) { + parts.push(eta); + } + + if (unit.CurrentStatusText) { + parts.push(unit.CurrentStatusText); + } + + return parts; +}; + +/** Secondary line for a personnel row. */ +export const personnelDetailParts = (person: PersonnelRecommendationData): string[] => { + const parts: string[] = []; + + if (person.RoleName) { + parts.push(person.RoleName); + } + + if (person.StationGroupName) { + parts.push(person.StationGroupName); + } + + const distance = formatDistance(person.DistanceMeters); + if (distance) { + parts.push(distance); + } + + const eta = formatEta(person.EtaSeconds); + if (eta) { + parts.push(eta); + } + + if (person.CurrentStatusText) { + parts.push(person.CurrentStatusText); + } + + return parts; +}; diff --git a/src/models/v4/calls/callResultData.ts b/src/models/v4/calls/callResultData.ts index 51e1147f..8117f67a 100644 --- a/src/models/v4/calls/callResultData.ts +++ b/src/models/v4/calls/callResultData.ts @@ -29,6 +29,13 @@ export class CallResultData { public IncidentId: string = ''; public AudioFileId: string = ''; public Type: string = 'No Type'; + /** + * Current alarm level (1-based). Only moves above 1 when the call has been escalated through a + * run card; the server normalises pre-run-card calls to 1. + */ + public AlarmLevel: number = 1; + /** The run card driving this call's dispatch, or null when no card matched. */ + public ActiveRunCardId: number | null = null; public LoggedOnUtc: string = ''; public DispatchedOn: string = ''; public DispatchedOnUtc: string = ''; diff --git a/src/models/v4/calls/newCallFieldPolicyResultData.ts b/src/models/v4/calls/newCallFieldPolicyResultData.ts new file mode 100644 index 00000000..5b2fea62 --- /dev/null +++ b/src/models/v4/calls/newCallFieldPolicyResultData.ts @@ -0,0 +1,38 @@ +/** + * The department's new-call form policy: which built-in fields the call form shows, and which it + * requires before a call can be created and sent to the field. + * + * Mirrors Resgrid.Model.NewCallFieldKeys. Keys are stable strings rather than ordinals because they + * are a wire contract shared by the web app and all five client apps. + */ + +export const NewCallFieldKeys = { + Address: 'address', + Geolocation: 'geolocation', + What3Words: 'what3words', + PlusCode: 'pluscode', + DestinationPoi: 'destinationPoi', + IndoorLocation: 'indoorLocation', + Note: 'note', + ContactName: 'contactName', + ContactInfo: 'contactInfo', + ExternalId: 'externalId', + IncidentId: 'incidentId', + ReferenceId: 'referenceId', + Protocols: 'protocols', + LinkedCall: 'linkedCall', + DispatchOn: 'dispatchOn', + DispatchList: 'dispatchList', +} as const; + +export type NewCallFieldKey = (typeof NewCallFieldKeys)[keyof typeof NewCallFieldKeys]; + +export interface NewCallFieldRuleData { + Key: string; + Visible: boolean; + Required: boolean; +} + +export interface NewCallFieldPolicyResultData { + Rules: NewCallFieldRuleData[]; +} diff --git a/src/models/v4/configs/getConfigResultData.ts b/src/models/v4/configs/getConfigResultData.ts index 4a702119..da17be72 100644 --- a/src/models/v4/configs/getConfigResultData.ts +++ b/src/models/v4/configs/getConfigResultData.ts @@ -16,4 +16,10 @@ export class GetConfigResultData { public NovuApplicationId: string = ''; public AnalyticsApiKey: string = ''; public AnalyticsHost: string = ''; + /** Department default map center latitude — every map opens here when it has nothing better. */ + public MapCenterLatitude: number = 0; + /** Department default map center longitude. */ + public MapCenterLongitude: number = 0; + /** Zoom level for department-wide maps. */ + public MapCenterZoomLevel: number = 9; } diff --git a/src/models/v4/runcards/dispatchRecommendationResultData.ts b/src/models/v4/runcards/dispatchRecommendationResultData.ts new file mode 100644 index 00000000..7a691c79 --- /dev/null +++ b/src/models/v4/runcards/dispatchRecommendationResultData.ts @@ -0,0 +1,123 @@ +/** + * Mirrors `Resgrid.Model.DispatchRecommendationResult` and its children. Field names match the API + * payload exactly (PascalCase) — these are deserialized straight from JSON, never constructed. + */ + +/** Mirrors `RecommendationSelectionReasons`. Why the engine picked this resource. */ +export enum RecommendationSelectionReason { + Unknown = 0, + /** Resource belongs to the station whose geofence contains the call. */ + InGeofence = 1, + /** Pulled from a next-nearest station after the owning station fell short. */ + CascadeStation = 2, + /** Closest-unit mode pick by straight-line distance. */ + ClosestByDistance = 3, + /** Closest-unit mode pick re-ranked by routed ETA. */ + ClosestByEta = 4, + /** Resource was inside its rest period, but nothing rested could fill the requirement. */ + RestPeriodOverridden = 5, +} + +/** Mirrors `RequirementShortfallReasons`. Why a requirement could not be filled. */ +export enum RequirementShortfallReason { + Unknown = 0, + NoCandidatesAvailable = 1, + OutsideRadius = 2, + LocationsTooStale = 3, + NoLocationData = 4, + UnitsNotStaffed = 5, + AllInRestPeriod = 6, + StationsExhausted = 7, +} + +/** Mirrors `DispatchRecommendationModes`. */ +export enum DispatchRecommendationMode { + /** Run cards match but never select resources — the dispatcher picks manually. */ + ManualOnly = 0, + /** Resources drawn from the station whose geofence contains the call, cascading outward. */ + StationBased = 1, + /** Resources ordered by distance (optionally routed ETA) from their last known fix. */ + ClosestUnit = 2, +} + +export interface UnitRecommendationData { + UnitId: number; + UnitName: string; + UnitTypeId: number; + UnitTypeName: string; + StationGroupId: number | null; + StationGroupName: string | null; + SelectionReason: RecommendationSelectionReason; + /** How many stations out the cascade went (0 = owning/containing station). */ + CascadeDepth: number; + DistanceMeters: number | null; + EtaSeconds: number | null; + LocationTimestamp: string | null; + LocationIsStale: boolean; + CurrentStatusText: string | null; + StaffingLevel: number | null; + SatisfiesRequirementId: number; +} + +export interface PersonnelRecommendationData { + UserId: string; + Name: string; + RoleId: number; + RoleName: string; + StationGroupId: number | null; + StationGroupName: string | null; + SelectionReason: RecommendationSelectionReason; + CascadeDepth: number; + DistanceMeters: number | null; + EtaSeconds: number | null; + LocationTimestamp: string | null; + LocationIsStale: boolean; + CurrentStatusText: string | null; + SatisfiesRequirementId: number; +} + +export interface RequirementShortfallData { + /** true = unit type requirement, false = personnel role requirement. */ + IsUnitRequirement: boolean; + RequirementId: number; + TypeOrRoleId: number; + TypeOrRoleName: string; + AlarmLevel: number; + RequiredCount: number; + FilledCount: number; + Reason: RequirementShortfallReason; +} + +export interface MoveUpRecommendationData { + StationGroupId: number; + StationGroupName: string; + UnitTypeId: number | null; + UnitTypeName: string | null; + PersonnelRoleId: number | null; + PersonnelRoleName: string | null; + MinimumRequired: number; + AvailableAfterDispatch: number; + SuggestedUnitId: number | null; + SuggestedUnitName: string | null; + SuggestedUserId: string | null; + SuggestedUserName: string | null; + FromStationGroupId: number | null; + FromStationGroupName: string | null; + DistanceMeters: number | null; +} + +export interface DispatchRecommendationResultData { + /** Null when no run card matched — the whole result is then a no-op. */ + MatchedRunCardId: number | null; + MatchedRunCardName: string | null; + AlarmLevel: number; + ModeUsed: DispatchRecommendationMode; + /** Resolved auto-dispatch decision (department default plus any card override). */ + AutoDispatch: boolean; + Units: UnitRecommendationData[]; + Personnel: PersonnelRecommendationData[]; + Shortfalls: RequirementShortfallData[]; + MoveUps: MoveUpRecommendationData[]; + /** Human-readable decision log from the engine. */ + Notes: string[]; +} diff --git a/src/models/v4/runcards/runCardResultData.ts b/src/models/v4/runcards/runCardResultData.ts new file mode 100644 index 00000000..907c98b3 --- /dev/null +++ b/src/models/v4/runcards/runCardResultData.ts @@ -0,0 +1,68 @@ +/** Mirrors `RunCardTriggerTypes`. */ +export enum RunCardTriggerType { + Priority = 0, + CallType = 1, + Both = 2, +} + +export interface RunCardTriggerData { + RunCardTriggerId: number; + /** 0 = priority, 1 = call type, 2 = both. */ + TriggerType: RunCardTriggerType; + /** Call priority (system 0-3 or DepartmentCallPriorityId). */ + Priority: number | null; + CallTypeId: number | null; + /** Optional window start (UTC). */ + StartsOn: string | null; + /** Optional window end (UTC). */ + EndsOn: string | null; +} + +export interface RunCardUnitRequirementData { + RunCardUnitRequirementId: number; + UnitTypeId: number; + RequiredCount: number; + SortOrder: number; +} + +export interface RunCardRoleRequirementData { + RunCardRoleRequirementId: number; + PersonnelRoleId: number; + RequiredCount: number; + SortOrder: number; +} + +export interface RunCardAlarmLevelData { + RunCardAlarmLevelId: number; + /** 1-based level number. Levels are additive: striking level N dispatches only level N. */ + AlarmLevel: number; + Name: string | null; + UnitRequirements: RunCardUnitRequirementData[]; + RoleRequirements: RunCardRoleRequirementData[]; +} + +export interface RunCardSelectionData { + RunCardAvailabilitySelectionId: number; + /** 1 = unit status, 2 = personnel status, 3 = staffing. */ + SelectionType: number; + UnitTypeId: number | null; + IsCustomState: boolean; + StateId: number; +} + +/** A run card (CAD-style response plan) with its full child graph. */ +export interface RunCardResultData { + RunCardId: number; + Name: string; + Description: string | null; + IsDisabled: boolean; + /** null = department default, 0 = manual only, 1 = station based, 2 = closest unit. */ + DispatchModeOverride: number | null; + /** null = department default, 0 = pre-populate, 1 = auto. */ + AutoDispatchOverride: number | null; + MinimumStaffingLevelOverride: number | null; + HomeStationGroupId: number | null; + Triggers: RunCardTriggerData[]; + AlarmLevels: RunCardAlarmLevelData[]; + Selections: RunCardSelectionData[]; +} diff --git a/src/stores/auth/store.tsx b/src/stores/auth/store.tsx index d4e9c9c0..a3e4de81 100644 --- a/src/stores/auth/store.tsx +++ b/src/stores/auth/store.tsx @@ -4,6 +4,8 @@ import { MMKV } from 'react-native-mmkv'; import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; +import { cacheManager } from '@/lib/cache/cache-manager'; +import { clearCacheScope, setCacheScope } from '@/lib/cache/cache-scope'; import { logger } from '@/lib/logging'; import { clearPasswordVerificationHash, loginRequest, storePasswordVerificationHash } from '../../lib/auth/api'; @@ -274,4 +276,42 @@ initTokenRefresh({ }, }); +// Keep the API cache scoped to whoever is signed in. Cache keys embed this identity, so stamping it +// here means a second user on the same device can never be served the first user's cached rosters, +// units or contacts -- and signing out drops the scope so nothing leaks into an anonymous session. +useAuthStore.subscribe((state, previousState) => { + if (state.userId === previousState.userId) { + return; + } + + try { + // Drop everything the previous identity cached before the new scope goes live, so nothing from + // the old account can be read back even if a key were to collide. + cacheManager.clear(); + } catch (error) { + // Cache hygiene must never be able to break sign-in or sign-out. Stale entries expire on their + // own, and the scope moved on below, so they are no longer addressable by the new identity. + logger.warn({ + message: 'Failed to clear the API cache on identity change', + context: { error }, + }); + } + + // Deliberately outside the clear() attempt: leaving the scope on the previous user is the one + // failure that actually leaks, since cache keys embed it and the entries we just failed to drop + // are still there. The new identity has to take over the scope whether or not the clear worked. + try { + if (state.userId) { + setCacheScope({ userId: state.userId }); + } else { + clearCacheScope(); + } + } catch (error) { + logger.warn({ + message: 'Failed to reset the API cache scope on identity change', + context: { error }, + }); + } +}); + export default useAuthStore; diff --git a/src/stores/dispatch/store.ts b/src/stores/dispatch/store.ts index 88636850..1f5e8228 100644 --- a/src/stores/dispatch/store.ts +++ b/src/stores/dispatch/store.ts @@ -3,6 +3,7 @@ import { create } from 'zustand'; import { getAllGroups } from '@/api/groups/groups'; import { getAllPersonnelInfos } from '@/api/personnel/personnel'; import { getUnits } from '@/api/units/units'; +import { logger } from '@/lib/logging'; export interface DispatchSelection { everyone: boolean; @@ -24,13 +25,24 @@ export interface DispatchData { units: DispatchItem[]; } +export interface DispatchLoadFailures { + users: boolean; + groups: boolean; + units: boolean; +} + interface DispatchState { data: DispatchData; selection: DispatchSelection; isLoading: boolean; error: string | null; + /** + * Which sections failed to load. Previously one failing request emptied the whole picker and the + * dispatcher saw only "Everyone" -- indistinguishable from a department with no units or crew. + */ + loadFailures: DispatchLoadFailures; searchQuery: string; - fetchDispatchData: () => Promise; + fetchDispatchData: (forceRefresh?: boolean) => Promise; setSelection: (selection: DispatchSelection) => void; toggleEveryone: () => void; toggleUser: (userId: string) => void; @@ -60,55 +72,75 @@ export const useDispatchStore = create((set, get) => ({ selection: initialSelection, isLoading: false, error: null, + loadFailures: { users: false, groups: false, units: false }, searchQuery: '', - fetchDispatchData: async () => { + fetchDispatchData: async (forceRefresh = false) => { set({ isLoading: true, error: null }); - try { - const [personnelResult, groupsResult, unitsResult] = await Promise.all([getAllPersonnelInfos(''), getAllGroups(), getUnits()]); - - const users: DispatchItem[] = (personnelResult?.Data ?? []).map((p) => ({ - Id: p.UserId, - Name: `${p.FirstName} ${p.LastName}`.trim(), - })); - - const groups: DispatchItem[] = (groupsResult?.Data ?? []).map((g) => ({ - Id: g.GroupId, - Name: g.Name, - })); - - const units: DispatchItem[] = (unitsResult?.Data ?? []).map((u) => ({ - Id: u.UnitId, - Name: u.Name, - })); - - // Extract unique roles from personnel data - const roleSet = new Map(); - (personnelResult?.Data ?? []).forEach((p) => { - if (p.Roles) { - p.Roles.forEach((role) => { - if (role && !roleSet.has(role)) { - roleSet.set(role, role); - } - }); - } - }); - const roles: DispatchItem[] = Array.from(roleSet.entries()).map(([name]) => ({ - Id: name, - Name: name, - })); - set({ - data: { users, groups, roles, units }, - isLoading: false, - }); - } catch (error) { - console.error('fetchDispatchData failed:', error); - set({ - error: 'Failed to fetch dispatch data', - isLoading: false, - }); + // allSettled, not all: personnel, groups and units are independent lists, and a dispatcher who + // can still see units must not lose them because the personnel call failed. + const [personnelSettled, groupsSettled, unitsSettled] = await Promise.allSettled([getAllPersonnelInfos(''), getAllGroups(), getUnits(forceRefresh)]); + + const personnelResult = personnelSettled.status === 'fulfilled' ? personnelSettled.value : null; + const groupsResult = groupsSettled.status === 'fulfilled' ? groupsSettled.value : null; + const unitsResult = unitsSettled.status === 'fulfilled' ? unitsSettled.value : null; + + const loadFailures = { + users: personnelSettled.status === 'rejected', + groups: groupsSettled.status === 'rejected', + units: unitsSettled.status === 'rejected', + }; + + if (personnelSettled.status === 'rejected') { + logger.error({ message: 'Failed to load dispatch personnel', context: { error: personnelSettled.reason } }); + } + if (groupsSettled.status === 'rejected') { + logger.error({ message: 'Failed to load dispatch groups', context: { error: groupsSettled.reason } }); } + if (unitsSettled.status === 'rejected') { + logger.error({ message: 'Failed to load dispatch units', context: { error: unitsSettled.reason } }); + } + + const users: DispatchItem[] = (personnelResult?.Data ?? []).map((p) => ({ + Id: p.UserId, + Name: `${p.FirstName} ${p.LastName}`.trim(), + })); + + const groups: DispatchItem[] = (groupsResult?.Data ?? []).map((g) => ({ + Id: g.GroupId, + Name: g.Name, + })); + + const units: DispatchItem[] = (unitsResult?.Data ?? []).map((u) => ({ + Id: u.UnitId, + Name: u.Name, + })); + + // Extract unique roles from personnel data + const roleSet = new Map(); + (personnelResult?.Data ?? []).forEach((p) => { + if (p.Roles) { + p.Roles.forEach((role) => { + if (role && !roleSet.has(role)) { + roleSet.set(role, role); + } + }); + } + }); + const roles: DispatchItem[] = Array.from(roleSet.entries()).map(([name]) => ({ + Id: name, + Name: name, + })); + + set({ + data: { users, groups, roles, units }, + loadFailures, + // Only a total failure is a modal-level error; a partial one is reported per section so the + // dispatcher can still work with whatever loaded. + error: loadFailures.users && loadFailures.groups && loadFailures.units ? 'Failed to fetch dispatch data' : null, + isLoading: false, + }); }, setSelection: (selection: DispatchSelection) => { diff --git a/src/stores/feature-flags/store.ts b/src/stores/feature-flags/store.ts index f93b7a9f..a6c1930a 100644 --- a/src/stores/feature-flags/store.ts +++ b/src/stores/feature-flags/store.ts @@ -11,6 +11,7 @@ import { securityStore } from '../security/store'; // Well-known feature flag keys. Keep values in sync with Resgrid.Model.FeatureFlagKeys. export const FeatureFlagKeys = { ChatSystem: 'Chat.System', + DispatchRunCards: 'Dispatch.RunCards', } as const; export type FeatureFlagKey = (typeof FeatureFlagKeys)[keyof typeof FeatureFlagKeys]; @@ -106,6 +107,13 @@ export const useFeatureFlag = (key: string, defaultValue = false) => featureFlag export const useIsChatEnabled = () => useFeatureFlag(FeatureFlagKeys.ChatSystem); +/** + * Run cards gate every recommendation and escalation surface in the app. The server refuses + * `RunCards/GetRecommendation` (404) and `Calls/EscalateCall` (400) when the flag is off, so the UI + * must stay hidden rather than offer actions that cannot succeed. + */ +export const useIsRunCardsEnabled = () => useFeatureFlag(FeatureFlagKeys.DispatchRunCards); + export type FeatureFlagStatus = 'unknown' | 'enabled' | 'disabled'; // Tri-state hook for gating that must not act before flags resolve (e.g. redirecting away @@ -121,3 +129,8 @@ export const useFeatureFlagStatus = (key: string): FeatureFlagStatus => }); export const useChatSystemStatus = (): FeatureFlagStatus => useFeatureFlagStatus(FeatureFlagKeys.ChatSystem); + +export const useRunCardsStatus = (): FeatureFlagStatus => useFeatureFlagStatus(FeatureFlagKeys.DispatchRunCards); + +/** Non-reactive read for imperative paths (stores, effects) that must not fire a gated request. */ +export const isRunCardsEnabled = (): boolean => featureFlagsStore.getState().isEnabled(FeatureFlagKeys.DispatchRunCards); diff --git a/src/stores/runcards/__tests__/store.test.ts b/src/stores/runcards/__tests__/store.test.ts new file mode 100644 index 00000000..c06a0253 --- /dev/null +++ b/src/stores/runcards/__tests__/store.test.ts @@ -0,0 +1,132 @@ +import { escalateCall, getDispatchRecommendation } from '@/api/runcards/runcards'; +import { isRunCardsEnabled } from '@/stores/feature-flags/store'; +import { useRunCardsStore } from '@/stores/runcards/store'; + +jest.mock('@/api/runcards/runcards', () => ({ + getDispatchRecommendation: jest.fn(), + escalateCall: jest.fn(), +})); + +jest.mock('@/stores/feature-flags/store', () => ({ + isRunCardsEnabled: jest.fn(() => true), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn() }, +})); + +const mockedGetRecommendation = getDispatchRecommendation as jest.Mock; +const mockedEscalate = escalateCall as jest.Mock; +const mockedIsEnabled = isRunCardsEnabled as jest.Mock; + +const validRequest = { priority: 1, type: 'Structure Fire', latitude: 51.1, longitude: 3.8, alarmLevel: 1 }; + +describe('run cards store', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedIsEnabled.mockReturnValue(true); + useRunCardsStore.getState().clear(); + }); + + describe('feature gating', () => { + it('does not call the API when Dispatch.RunCards is off', async () => { + // The server 404s this endpoint with the toggle off; a client that calls anyway would render + // a permanent error on a department that simply does not use run cards. + mockedIsEnabled.mockReturnValue(false); + + await useRunCardsStore.getState().fetchRecommendation(validRequest); + + expect(mockedGetRecommendation).not.toHaveBeenCalled(); + expect(useRunCardsStore.getState().recommendation).toBeNull(); + expect(useRunCardsStore.getState().hasFetched).toBe(false); + expect(useRunCardsStore.getState().error).toBeNull(); + }); + + it('does not escalate when Dispatch.RunCards is off', async () => { + mockedIsEnabled.mockReturnValue(false); + + const result = await useRunCardsStore.getState().escalate('42'); + + expect(mockedEscalate).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + }); + + describe('fetchRecommendation', () => { + it('skips the request until both trigger inputs are present', async () => { + // Run cards match on priority and/or call type; asking before the dispatcher picked them + // just burns a round trip through the whole selection engine. + await useRunCardsStore.getState().fetchRecommendation({ ...validRequest, type: '' }); + await useRunCardsStore.getState().fetchRecommendation({ ...validRequest, priority: undefined as unknown as number }); + + expect(mockedGetRecommendation).not.toHaveBeenCalled(); + }); + + it('stores a matched recommendation', async () => { + const recommendation = { MatchedRunCardId: 3, Units: [], Personnel: [] }; + mockedGetRecommendation.mockResolvedValue(recommendation); + + await useRunCardsStore.getState().fetchRecommendation(validRequest); + + expect(mockedGetRecommendation).toHaveBeenCalledTimes(1); + expect(useRunCardsStore.getState().recommendation).toBe(recommendation); + expect(useRunCardsStore.getState().hasFetched).toBe(true); + expect(useRunCardsStore.getState().isLoading).toBe(false); + }); + + it('records a null result as "asked, nothing matched"', async () => { + mockedGetRecommendation.mockResolvedValue(null); + + await useRunCardsStore.getState().fetchRecommendation(validRequest); + + expect(useRunCardsStore.getState().recommendation).toBeNull(); + expect(useRunCardsStore.getState().hasFetched).toBe(true); + expect(useRunCardsStore.getState().error).toBeNull(); + }); + + it('surfaces a failure without blocking the manual flow', async () => { + mockedGetRecommendation.mockRejectedValue(new Error('boom')); + + await useRunCardsStore.getState().fetchRecommendation(validRequest); + + expect(useRunCardsStore.getState().error).toBe('boom'); + expect(useRunCardsStore.getState().recommendation).toBeNull(); + expect(useRunCardsStore.getState().isLoading).toBe(false); + }); + }); + + describe('escalate', () => { + it('returns the server result on success', async () => { + const result = { Id: '42', Success: true, NewAlarmLevel: 2, AddedUnits: 2, AddedPersonnel: 1 }; + mockedEscalate.mockResolvedValue(result); + + await expect(useRunCardsStore.getState().escalate('42')).resolves.toBe(result); + expect(useRunCardsStore.getState().isEscalating).toBe(false); + expect(useRunCardsStore.getState().escalationError).toBeNull(); + }); + + it('captures the error and stops the spinner on failure', async () => { + mockedEscalate.mockRejectedValue(new Error('nope')); + + await expect(useRunCardsStore.getState().escalate('42')).resolves.toBeNull(); + expect(useRunCardsStore.getState().isEscalating).toBe(false); + expect(useRunCardsStore.getState().escalationError).toBe('nope'); + }); + }); + + describe('clear', () => { + it('resets everything so the next call starts clean', async () => { + mockedGetRecommendation.mockResolvedValue({ MatchedRunCardId: 3, Units: [], Personnel: [] }); + await useRunCardsStore.getState().fetchRecommendation(validRequest); + useRunCardsStore.getState().markApplied(3); + + useRunCardsStore.getState().clear(); + + const state = useRunCardsStore.getState(); + expect(state.recommendation).toBeNull(); + expect(state.hasFetched).toBe(false); + expect(state.appliedRunCardId).toBeNull(); + expect(state.error).toBeNull(); + }); + }); +}); diff --git a/src/stores/runcards/store.ts b/src/stores/runcards/store.ts new file mode 100644 index 00000000..de62d085 --- /dev/null +++ b/src/stores/runcards/store.ts @@ -0,0 +1,161 @@ +import { create } from 'zustand'; + +import { escalateCall as escalateCallApi, type EscalateCallResultData, getDispatchRecommendation, type RecommendationRequest } from '@/api/runcards/runcards'; +import { logger } from '@/lib/logging'; +import { type DispatchRecommendationResultData } from '@/models/v4/runcards/dispatchRecommendationResultData'; + +import { isRunCardsEnabled } from '../feature-flags/store'; + +/** + * Holds the run card recommendation for the call currently being composed or edited. + * + * The recommendation is a *preview*: nothing is dispatched by fetching it. The dispatcher applies + * it into the normal dispatch selection, which then rides the existing manual pipeline — the same + * stance the web New Call page takes. Auto-dispatch, when the department has it on, happens + * server-side at call creation and never needs the app to do anything. + */ + +/** Debounce for the auto-fetch: priority/type/location all change as the dispatcher types. */ +const RECOMMENDATION_DEBOUNCE_MS = 600; + +interface RunCardsState { + recommendation: DispatchRecommendationResultData | null; + isLoading: boolean; + error: string | null; + /** True once a fetch settled, so the UI can tell "not asked yet" from "asked, no card matched". */ + hasFetched: boolean; + /** Recommendation ids the dispatcher has already applied, so the panel can show it as applied. */ + appliedRunCardId: number | null; + + isEscalating: boolean; + escalationError: string | null; + + fetchRecommendation: (request: RecommendationRequest) => Promise; + fetchRecommendationDebounced: (request: RecommendationRequest) => void; + markApplied: (runCardId: number | null) => void; + clear: () => void; + escalate: (callId: string) => Promise; +} + +let debounceTimer: ReturnType | null = null; +let inFlightController: AbortController | null = null; + +export const useRunCardsStore = create((set, get) => ({ + recommendation: null, + isLoading: false, + error: null, + hasFetched: false, + appliedRunCardId: null, + isEscalating: false, + escalationError: null, + + fetchRecommendation: async (request: RecommendationRequest) => { + // Gated client-side as well as server-side: with the toggle off the endpoint 404s, and a 404 + // rendered as an error would be a permanent scary banner on a department that simply does not + // use run cards. + if (!isRunCardsEnabled()) { + set({ recommendation: null, isLoading: false, error: null, hasFetched: false }); + return; + } + + // A recommendation is meaningless without both trigger inputs — run cards match on priority + // and/or call type, so asking before the dispatcher has picked them just wastes a round trip. + if (typeof request.priority !== 'number' || !request.type) { + set({ recommendation: null, isLoading: false, error: null, hasFetched: false }); + return; + } + + inFlightController?.abort(); + const controller = new AbortController(); + inFlightController = controller; + + set({ isLoading: true, error: null }); + + try { + const recommendation = await getDispatchRecommendation(request, controller.signal); + + // A newer request superseded this one while it was in flight. + if (inFlightController !== controller) { + return; + } + + set({ recommendation, isLoading: false, hasFetched: true, error: null }); + } catch (error) { + if (controller.signal.aborted || inFlightController !== controller) { + return; + } + + logger.error({ + message: 'Failed to fetch run card recommendation', + context: { error }, + }); + + // Never block call creation on this: the panel shows a quiet failure and the dispatcher + // carries on selecting resources by hand. + set({ + recommendation: null, + isLoading: false, + hasFetched: true, + error: error instanceof Error ? error.message : 'Failed to fetch recommendation', + }); + } + }, + + fetchRecommendationDebounced: (request: RecommendationRequest) => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + debounceTimer = setTimeout(() => { + debounceTimer = null; + void get().fetchRecommendation(request); + }, RECOMMENDATION_DEBOUNCE_MS); + }, + + markApplied: (runCardId: number | null) => { + set({ appliedRunCardId: runCardId }); + }, + + clear: () => { + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + inFlightController?.abort(); + inFlightController = null; + + set({ + recommendation: null, + isLoading: false, + error: null, + hasFetched: false, + appliedRunCardId: null, + isEscalating: false, + escalationError: null, + }); + }, + + escalate: async (callId: string) => { + if (!isRunCardsEnabled()) { + return null; + } + + set({ isEscalating: true, escalationError: null }); + + try { + const result = await escalateCallApi(callId); + set({ isEscalating: false }); + return result; + } catch (error) { + logger.error({ + message: 'Failed to escalate call alarm level', + context: { error, callId }, + }); + set({ + isEscalating: false, + escalationError: error instanceof Error ? error.message : 'Failed to escalate call', + }); + return null; + } + }, +})); diff --git a/src/stores/security/__tests__/store.test.ts b/src/stores/security/__tests__/store.test.ts index 8edec2d1..67153c73 100644 --- a/src/stores/security/__tests__/store.test.ts +++ b/src/stores/security/__tests__/store.test.ts @@ -55,11 +55,18 @@ jest.mock('react-native', () => ({ }, })); +// Mock the API cache scope +jest.mock('@/lib/cache/cache-scope', () => ({ + setCacheScope: jest.fn(), +})); + // Import after mocks import { securityStore, useSecurityStore } from '../store'; import { getCurrentUsersRights } from '@/api/security/security'; +import { setCacheScope } from '@/lib/cache/cache-scope'; const mockGetCurrentUsersRights = getCurrentUsersRights as jest.MockedFunction; +const mockSetCacheScope = setCacheScope as jest.MockedFunction; describe('useSecurityStore', () => { @@ -269,4 +276,71 @@ describe('useSecurityStore', () => { expect(result.current.isUserGroupAdmin(1)).toBe(false); }); }); + + // Cache keys embed the department, so a user who moves between departments must not be served the + // previous department's cached rosters, units or contacts. + describe('API cache scope', () => { + it('stamps the department on the cache scope once rights resolve', async () => { + mockGetCurrentUsersRights.mockResolvedValue({ + Data: mockRightsData, + PageSize: 0, + Timestamp: '', + Version: '', + Node: '', + RequestId: '', + Status: '', + Environment: '', + }); + + mockSetCacheScope.mockClear(); + + await act(async () => { + await securityStore.getState().getRights(); + }); + + expect(mockSetCacheScope).toHaveBeenCalledWith({ departmentId: 'dept-123' }); + }); + + it('moves the scope with the department, not just the first fetch', () => { + act(() => { + securityStore.setState({ rights: mockRightsData, error: null }); + }); + + mockSetCacheScope.mockClear(); + + act(() => { + securityStore.setState({ rights: { ...mockRightsData, DepartmentId: 'dept-456' }, error: null }); + }); + + expect(mockSetCacheScope).toHaveBeenCalledWith({ departmentId: 'dept-456' }); + }); + + it('leaves the scope alone when rights change but the department does not', () => { + act(() => { + securityStore.setState({ rights: mockRightsData, error: null }); + }); + + mockSetCacheScope.mockClear(); + + act(() => { + securityStore.setState({ rights: { ...mockRightsData, IsAdmin: false }, error: null }); + }); + + expect(mockSetCacheScope).not.toHaveBeenCalled(); + }); + + it('drops the department from the scope when rights go away', () => { + act(() => { + securityStore.setState({ rights: mockRightsData, error: null }); + }); + + mockSetCacheScope.mockClear(); + + act(() => { + securityStore.setState({ rights: null, error: null }); + }); + + expect(mockSetCacheScope).toHaveBeenCalledWith({ departmentId: null }); + }); + }); }); diff --git a/src/stores/security/store.ts b/src/stores/security/store.ts index 25537cac..e2704a57 100644 --- a/src/stores/security/store.ts +++ b/src/stores/security/store.ts @@ -4,6 +4,7 @@ import { create, type StateCreator } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; import { getCurrentUsersRights } from '@/api/security/security'; +import { setCacheScope } from '@/lib/cache/cache-scope'; import { logger } from '@/lib/logging'; import { type DepartmentRightsResultData } from '@/models/v4/security/departmentRightsResultData'; @@ -33,6 +34,23 @@ const mmkvStorage = { }, }; +/** + * The department half of the API cache scope. Rights are where the department id is resolved, so + * this store owns stamping it: cache keys embed it, and without it a user who moves between + * departments reads the previous department's cached rosters, units and contacts back out of MMKV. + */ +const applyDepartmentCacheScope = (departmentId: string | null | undefined): void => { + try { + setCacheScope({ departmentId: departmentId ? String(departmentId) : null }); + } catch (error) { + // Never let cache bookkeeping break sign-in. A stale scope only costs a cache miss. + logger.warn({ + message: 'Failed to apply the department to the API cache scope', + context: { error }, + }); + } +}; + // Base store creator without persistence const createSecurityStore: StateCreator = (set, _get) => ({ error: null, @@ -44,6 +62,8 @@ const createSecurityStore: StateCreator = (set, _get) => ({ set({ rights: response.Data, }); + + applyDepartmentCacheScope(response.Data?.DepartmentId); } catch (error) { logger.error({ message: 'Failed to get user rights', @@ -70,6 +90,21 @@ export const securityStore = }) ); +// Rights also arrive without going through getRights — persisted rights rehydrate during store +// creation above, and a department switch lands as a plain state change. Keep the scope in step with +// whatever the rights currently say rather than only with the fetch path. +securityStore.subscribe((state, previousState) => { + if (state.rights?.DepartmentId === previousState.rights?.DepartmentId) { + return; + } + + applyDepartmentCacheScope(state.rights?.DepartmentId ?? null); +}); + +// Rehydration finishes inside create(), before the subscription above exists, so stamp the scope +// once from whatever rights were restored. +applyDepartmentCacheScope(securityStore.getState().rights?.DepartmentId ?? null); + export const useSecurityStore = () => { const store = securityStore(); return { diff --git a/src/translations/ar.json b/src/translations/ar.json index 2f839c69..9f1c3ed4 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -295,6 +295,8 @@ "select_address_placeholder": "اختر عنوان المكالمة", "select_description": "اختر الوصف", "select_dispatch_recipients": "اختيار مستقبلي الإرسال", + "dispatch_recipients_partial_load": "تعذر تحميل بعض المستلمين. قد تكون القوائم أدناه غير مكتملة.", + "dispatch_recipients_empty": "لا يظهر لك أي أفراد أو مجموعات أو أدوار أو وحدات. تحقق من أذونات قسمك أو أرسل إلى الجميع.", "select_destination_poi": "اختر نقطة اهتمام الوجهة", "select_location": "اختر الموقع على الخريطة", "select_name": "اختر الاسم", @@ -369,7 +371,9 @@ "no_audio_description": "ستظهر هنا التسجيلات الصوتية المرفقة بهذا البلاغ.", "error": "تعذر تحميل الصوت", "audio_name": "مقطع صوتي" - } + }, + "required_fields_missing": "يطلب قسمك هذه الحقول قبل إنشاء البلاغ: {{fields}}", + "field_policy_loading": "لا يزال يتم تحميل إعدادات البلاغات في قسمك، يرجى المحاولة مرة أخرى بعد لحظات" }, "chat": { "title": "الدردشة", @@ -1215,6 +1219,7 @@ "english": "إنجليزي", "french": "فرنسي", "german": "ألماني", + "greek": "اليونانية", "italian": "إيطالي", "enter_password": "أدخل كلمة المرور الخاصة بك", "enter_server_url": "أدخل عنوان URL لواجهة برمجة تطبيقات Resgrid (مثال: https://api.resgrid.com)", @@ -1536,5 +1541,55 @@ }, "stats_label": "تنبيهات الطقس" }, - "welcome": "مرحبًا بك في موقع تطبيق obytes" + "welcome": "مرحبًا بك في موقع تطبيق obytes", + "run_cards": { + "title": "توصية خطة الاستجابة", + "checking": "جارٍ التحقق من خطط الاستجابة…", + "lookup_failed": "تعذر التحقق من خطط الاستجابة. لا يزال بإمكانك الإرسال يدويًا.", + "summary": "موصى بـ {{units}} وحدة، {{personnel}} مستجيب", + "units_section": "الوحدات الموصى بها ({{count}})", + "personnel_section": "الأفراد الموصى بهم ({{count}})", + "shortfalls_section": "تعذر توفيره", + "shortfall_line": "{{name}}: {{filled}} من {{required}} — {{reason}}", + "move_ups_section": "إعادة توزيع التغطية", + "move_up_line": "{{station}}: {{available}} من {{minimum}} بعد الإرسال — اقتراح {{resource}}", + "move_up_no_donor": "لم يُعثر على مصدر", + "notes_section": "كيف تم اتخاذ القرار", + "apply": "تطبيق التوصية", + "applied": "تم تطبيق التوصية", + "stale_location": "موقع قديم", + "auto_dispatch": "إرسال تلقائي", + "auto_dispatch_explainer": "يرسل هذا القسم خطط الاستجابة المطابقة تلقائيًا؛ يتم تنبيه هذه الموارد عند إنشاء البلاغ.", + "alarm_level": "إنذار {{level}}", + "alarm_level_label": "مستوى الإنذار", + "escalate": "إطلاق الإنذار {{level}}", + "escalate_confirm": "إطلاق الإنذار {{level}}؟ سيؤدي ذلك إلى إرسال موارد المستوى التالي وتنبيه الجدد فقط.", + "escalate_confirm_action": "إطلاق الإنذار", + "escalate_succeeded": "تم إطلاق الإنذار {{level}} — تمت إضافة {{units}} وحدة، {{personnel}} مستجيب", + "escalate_nothing_to_add": "لا شيء لإضافته في مستوى الإنذار التالي", + "escalate_failed": "تعذر رفع مستوى البلاغ. يرجى المحاولة مرة أخرى.", + "mode": { + "manual_only": "اختيار يدوي", + "station_based": "حسب المركز", + "closest_unit": "أقرب وحدة" + }, + "reason": { + "unknown": "تم اختياره", + "in_geofence": "داخل نطاق المركز", + "cascade_station": "أقرب مركز", + "closest_by_distance": "الأقرب مسافةً", + "closest_by_eta": "الأقرب زمنًا", + "rest_period_overridden": "تم تجاوز فترة الراحة" + }, + "shortfall": { + "unknown": "لم يُذكر سبب", + "no_candidates": "لا يوجد متاح", + "outside_radius": "خارج نطاق البحث", + "locations_stale": "المواقع قديمة جدًا", + "no_location_data": "لا توجد بيانات موقع", + "not_staffed": "طاقم غير كافٍ", + "all_in_rest_period": "الجميع في فترة راحة", + "stations_exhausted": "لا توجد مراكز أخرى للبحث" + } + } } diff --git a/src/translations/de.json b/src/translations/de.json index aaea2ec2..41747e42 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -296,6 +296,8 @@ "select_address_placeholder": "Adresse des Einsatzes auswählen", "select_description": "Beschreibung auswählen", "select_dispatch_recipients": "Alarmierungsempfänger auswählen", + "dispatch_recipients_partial_load": "Einige Empfänger konnten nicht geladen werden. Die Listen unten sind möglicherweise unvollständig.", + "dispatch_recipients_empty": "Für Sie sind keine Personen, Gruppen, Rollen oder Einheiten sichtbar. Prüfen Sie die Berechtigungen Ihrer Abteilung oder alarmieren Sie Alle.", "select_location": "Standort auf Karte auswählen", "select_name": "Name auswählen", "select_nature": "Art auswählen", @@ -369,7 +371,9 @@ "no_audio_description": "An diesen Einsatz angehängte Audioaufnahmen werden hier angezeigt.", "error": "Audio konnte nicht geladen werden", "audio_name": "Audioclip" - } + }, + "required_fields_missing": "Ihre Abteilung benötigt diese Felder, bevor ein Einsatz angelegt werden kann: {{fields}}", + "field_policy_loading": "Die Einsatzeinstellungen Ihrer Abteilung werden noch geladen, bitte versuchen Sie es gleich erneut" }, "chat": { "title": "Chat", @@ -1215,6 +1219,7 @@ "english": "Englisch", "french": "Französisch", "german": "Deutsch", + "greek": "Griechisch", "italian": "Italienisch", "enter_password": "Passwort eingeben", "enter_server_url": "Resgrid-API-URL eingeben (z. B. https://api.resgrid.com)", @@ -1536,5 +1541,55 @@ }, "stats_label": "Wetterwarnungen" }, - "welcome": "Willkommen bei der obytes App-Seite" + "welcome": "Willkommen bei der obytes App-Seite", + "run_cards": { + "title": "Alarmierungsplan-Empfehlung", + "checking": "Alarmierungspläne werden geprüft…", + "lookup_failed": "Alarmierungspläne konnten nicht geprüft werden. Manuelle Alarmierung ist weiterhin möglich.", + "summary": "{{units}} Einheit(en), {{personnel}} Einsatzkräfte empfohlen", + "units_section": "Empfohlene Einheiten ({{count}})", + "personnel_section": "Empfohlenes Personal ({{count}})", + "shortfalls_section": "Nicht erfüllbar", + "shortfall_line": "{{name}}: {{filled}} von {{required}} — {{reason}}", + "move_ups_section": "Nachrücken zur Abdeckung", + "move_up_line": "{{station}}: {{available}} von {{minimum}} nach der Alarmierung — Vorschlag {{resource}}", + "move_up_no_donor": "keine Quelle gefunden", + "notes_section": "Wie das entschieden wurde", + "apply": "Empfehlung übernehmen", + "applied": "Empfehlung übernommen", + "stale_location": "Veralteter Standort", + "auto_dispatch": "Automatische Alarmierung", + "auto_dispatch_explainer": "Diese Abteilung alarmiert passende Alarmierungspläne automatisch; diese Kräfte werden beim Anlegen des Einsatzes alarmiert.", + "alarm_level": "Alarmstufe {{level}}", + "alarm_level_label": "Alarmstufe", + "escalate": "Alarmstufe {{level}} auslösen", + "escalate_confirm": "Alarmstufe {{level}} auslösen? Damit werden die Kräfte der nächsten Stufe alarmiert, benachrichtigt werden nur die neu hinzugefügten.", + "escalate_confirm_action": "Alarmstufe auslösen", + "escalate_succeeded": "Alarmstufe {{level}} ausgelöst — {{units}} Einheit(en), {{personnel}} Einsatzkräfte hinzugefügt", + "escalate_nothing_to_add": "Auf der nächsten Alarmstufe gibt es nichts hinzuzufügen", + "escalate_failed": "Der Einsatz konnte nicht hochgestuft werden. Bitte erneut versuchen.", + "mode": { + "manual_only": "Manuelle Auswahl", + "station_based": "Nach Wache", + "closest_unit": "Nächste Einheit" + }, + "reason": { + "unknown": "Ausgewählt", + "in_geofence": "Im Wachbereich", + "cascade_station": "Nächstgelegene Wache", + "closest_by_distance": "Kürzeste Entfernung", + "closest_by_eta": "Kürzeste Fahrzeit", + "rest_period_overridden": "Ruhezeit übergangen" + }, + "shortfall": { + "unknown": "kein Grund angegeben", + "no_candidates": "nichts verfügbar", + "outside_radius": "außerhalb des Suchradius", + "locations_stale": "Standorte zu alt", + "no_location_data": "keine Standortdaten", + "not_staffed": "zu wenig Besatzung", + "all_in_rest_period": "alle in Ruhezeit", + "stations_exhausted": "keine weiteren Wachen" + } + } } diff --git a/src/translations/el.json b/src/translations/el.json new file mode 100644 index 00000000..a3406e87 --- /dev/null +++ b/src/translations/el.json @@ -0,0 +1,1595 @@ +{ + "app": { + "title": "Resgrid Dispatch" + }, + "audio_streams": { + "buffering": "Προσωρινή αποθήκευση", + "buffering_stream": "Προσωρινή αποθήκευση ροής ήχου...", + "close": "Κλείσιμο", + "currently_playing": "Αναπαράγεται τώρα: {{streamName}}", + "loading": "Φόρτωση", + "loading_stream": "Φόρτωση ροής ήχου...", + "loading_streams": "Φόρτωση ροών ήχου...", + "name": "Όνομα", + "no_stream_playing": "Δεν αναπαράγεται καμία ροή ήχου αυτή τη στιγμή", + "none": "Καμία", + "playing": "Αναπαραγωγή", + "refresh_streams": "Ανανέωση Ροών", + "select_placeholder": "Επιλέξτε ροή ήχου", + "select_stream": "Επιλογή Ροής Ήχου", + "status": "Κατάσταση", + "stopped": "Σταματημένη", + "stream_info": "Πληροφορίες Ροής", + "stream_selected": "Επιλεγμένη: {{streamName}}", + "title": "Ροές Ήχου", + "type": "Τύπος" + }, + "bluetooth": { + "applied": "Εφαρμόστηκε", + "audio": "Ήχος", + "audioActive": "Ήχος Ενεργός", + "audio_device": "Ακουστικό BT", + "availableDevices": "Διαθέσιμες Συσκευές", + "available_devices": "Διαθέσιμες Συσκευές", + "bluetooth_not_ready": "Το Bluetooth είναι {{state}}. Ενεργοποιήστε το Bluetooth.", + "buttonControlAvailable": "Διαθέσιμος έλεγχος κουμπιών", + "checking": "Έλεγχος κατάστασης Bluetooth...", + "clear": "Καθαρισμός", + "connect": "Σύνδεση", + "connected": "Συνδεδεμένη", + "connectionError": "Σφάλμα Σύνδεσης", + "current_selection": "Τρέχουσα Επιλογή", + "disconnect": "Αποσύνδεση", + "doublePress": "Διπλό ", + "liveKitActive": "LiveKit Ενεργό", + "longPress": "Παρατεταμένο ", + "micControl": "Έλεγχος Μικροφώνου", + "mute": "Σίγαση", + "noDevicesFound": "Δεν βρέθηκαν συσκευές ήχου", + "noDevicesFoundRetry": "Δεν βρέθηκαν συσκευές ήχου. Δοκιμάστε νέα σάρωση.", + "no_device_selected": "Δεν έχει επιλεγεί συσκευή", + "no_devices_found": "Δεν βρέθηκαν συσκευές ήχου Bluetooth", + "not_connected": "Μη συνδεδεμένη", + "poweredOff": "Το Bluetooth είναι απενεργοποιημένο. Ενεργοποιήστε το Bluetooth για να συνδέσετε συσκευές ήχου.", + "pttStart": "Έναρξη PTT", + "pttStop": "Λήξη PTT", + "recentButtonEvents": "Πρόσφατα Συμβάντα Κουμπιών", + "scan": "Σάρωση", + "scanAgain": "Νέα Σάρωση", + "scan_again": "Νέα Σάρωση", + "scan_error_message": "Δεν είναι δυνατή η σάρωση για συσκευές Bluetooth", + "scan_error_title": "Σφάλμα Σάρωσης", + "scanning": "Σάρωση...", + "select_device": "Επιλογή Συσκευής Bluetooth", + "selected": "Επιλεγμένη", + "selection_error_message": "Δεν είναι δυνατή η αποθήκευση της προτιμώμενης συσκευής", + "selection_error_title": "Σφάλμα Επιλογής", + "startScanning": "Έναρξη Σάρωσης", + "stopScan": "Διακοπή Σάρωσης", + "supports_mic_control": "Έλεγχος Μικροφώνου", + "tap_scan_to_find_devices": "Πατήστε «Σάρωση» για να βρείτε συσκευές ήχου Bluetooth", + "title": "Ήχος Bluetooth", + "unauthorized": "Η άδεια Bluetooth απορρίφθηκε. Παραχωρήστε άδειες Bluetooth στις Ρυθμίσεις.", + "unknown": "Άγνωστο", + "unknownDevice": "Άγνωστη Συσκευή", + "unknown_device": "Άγνωστη Συσκευή", + "unmute": "Κατάργηση σίγασης", + "volumeDown": "Ένταση -", + "volumeUp": "Ένταση +" + }, + "callImages": { + "add": "Προσθήκη Εικόνας", + "add_new": "Προσθήκη Νέας Εικόνας", + "default_name": "Εικόνα Χωρίς Τίτλο", + "error": "Σφάλμα κατά τη λήψη εικόνων", + "failed_to_load": "Αποτυχία φόρτωσης εικόνας", + "image_alt": "Εικόνα κλήσης", + "image_name": "Όνομα Εικόνας", + "image_note": "Σημείωση Εικόνας", + "loading": "Φόρτωση...", + "no_images": "Δεν υπάρχουν διαθέσιμες εικόνες", + "no_images_description": "Προσθέστε εικόνες στην κλήση σας για να βοηθήσετε στην τεκμηρίωση και την επικοινωνία", + "select_from_gallery": "Επιλογή από τη Συλλογή", + "take_photo": "Λήψη Φωτογραφίας", + "title": "Εικόνες Κλήσης", + "upload": "Μεταφόρτωση" + }, + "callNotes": { + "addNote": "Προσθήκη Σημείωσης", + "addNotePlaceholder": "Προσθέστε νέα σημείωση...", + "noNotes": "Δεν υπάρχουν διαθέσιμες σημειώσεις για αυτή την κλήση", + "noSearchResults": "Καμία σημείωση δεν ταιριάζει με την αναζήτησή σας", + "searchPlaceholder": "Αναζήτηση σημειώσεων...", + "title": "Σημειώσεις Κλήσης" + }, + "call_detail": { + "address": "Διεύθυνση", + "call_location": "Τοποθεσία Κλήσης", + "close_call": "Κλείσιμο Κλήσης", + "close_call_confirmation": "Είστε βέβαιοι ότι θέλετε να κλείσετε αυτή την κλήση;", + "close_call_error": "Αποτυχία κλεισίματος κλήσης", + "close_call_note": "Σημείωση Κλεισίματος", + "close_call_note_placeholder": "Εισαγάγετε μια σημείωση σχετικά με το κλείσιμο της κλήσης", + "close_call_success": "Η κλήση έκλεισε με επιτυχία", + "close_call_type": "Τύπος Κλεισίματος", + "close_call_type_placeholder": "Επιλέξτε τύπο κλεισίματος", + "close_call_type_required": "Επιλέξτε τύπο κλεισίματος", + "close_call_types": { + "cancelled": "Ακυρώθηκε", + "closed": "Έκλεισε", + "false_alarm": "Ψευδής Συναγερμός", + "founded": "Βάσιμη", + "minor": "Ήσσονος Σημασίας", + "transferred": "Μεταβιβάστηκε", + "unfounded": "Αβάσιμη" + }, + "contact_email": "Email", + "contact_info": "Στοιχεία Επικοινωνίας", + "contact_name": "Όνομα Επαφής", + "contact_phone": "Τηλέφωνο", + "destination": "Προορισμός", + "destination_address": "Διεύθυνση Προορισμού", + "destination_type": "Τύπος Προορισμού", + "edit_call": "Επεξεργασία Κλήσης", + "external_id": "Εξωτερικό Αναγνωριστικό", + "failed_to_open_maps": "Αποτυχία ανοίγματος εφαρμογής χαρτών", + "files": { + "add_file": "Προσθήκη Αρχείου", + "button": "Αρχεία", + "empty": "Δεν υπάρχουν διαθέσιμα αρχεία", + "empty_description": "Προσθέστε αρχεία στην κλήση σας για να βοηθήσετε στην τεκμηρίωση και την επικοινωνία", + "error": "Σφάλμα κατά τη λήψη αρχείων", + "file_name": "Όνομα Αρχείου", + "name_required": "Εισαγάγετε ένα όνομα για το αρχείο", + "no_files": "Δεν υπάρχουν διαθέσιμα αρχεία", + "no_files_description": "Προσθέστε αρχεία στην κλήση σας για να βοηθήσετε στην τεκμηρίωση και την επικοινωνία", + "open_error": "Σφάλμα κατά το άνοιγμα του αρχείου", + "select_error": "Σφάλμα κατά την επιλογή αρχείου", + "select_file": "Επιλογή Αρχείου", + "share_error": "Σφάλμα κατά την κοινή χρήση αρχείου", + "title": "Αρχεία Κλήσης", + "upload": "Μεταφόρτωση", + "upload_error": "Σφάλμα κατά τη μεταφόρτωση αρχείου", + "uploading": "Μεταφόρτωση..." + }, + "group": "Ομάδα", + "images": "Εικόνες", + "loading": "Φόρτωση λεπτομερειών κλήσης...", + "nature": "Φύση", + "no_additional_info": "Δεν υπάρχουν διαθέσιμες πρόσθετες πληροφορίες", + "no_contact_info": "Δεν υπάρχουν διαθέσιμα στοιχεία επικοινωνίας", + "no_dispatched": "Δεν έχουν σταλεί μονάδες σε αυτή την κλήση", + "no_location": "Δεν υπάρχουν διαθέσιμα δεδομένα τοποθεσίας", + "no_location_for_routing": "Δεν υπάρχουν διαθέσιμα δεδομένα τοποθεσίας για δρομολόγηση", + "no_protocols": "Δεν έχουν επισυναφθεί πρωτόκολλα σε αυτή την κλήση", + "no_timeline": "Δεν υπάρχουν διαθέσιμα συμβάντα χρονολογίου", + "not_available": "Μ/Δ", + "not_found": "Η κλήση δεν βρέθηκε", + "missing_call_id": "Λείπει το αναγνωριστικό κλήσης", + "note": "Σημείωση", + "notes": "Σημειώσεις", + "priority": "Προτεραιότητα", + "reference_id": "Αναγνωριστικό Αναφοράς", + "set_active": "Ορισμός ως Ενεργή", + "set_active_error": "Αποτυχία ορισμού της κλήσης ως ενεργής", + "set_active_success": "Η κλήση ορίστηκε ως ενεργή", + "setting_active": "Ορισμός ως Ενεργή...", + "status": "Κατάσταση", + "tabs": { + "contact": "Επαφή", + "dispatched": "Απεσταλμένες", + "info": "Πληροφορίες", + "protocols": "Πρωτόκολλα", + "timeline": "Δραστηριότητα", + "video": "Βίντεο" + }, + "timestamp": "Χρονική Σήμανση", + "scheduled_on": "Προγραμματισμένη Ημερομηνία και Ώρα Αποστολής", + "title": "Λεπτομέρειες Κλήσης", + "type": "Τύπος", + "unit": "Μονάδα", + "update_call_error": "Αποτυχία ενημέρωσης κλήσης", + "update_call_success": "Η κλήση ενημερώθηκε με επιτυχία", + "audio": "Ήχος", + "delete_call": "Διαγραφή Κλήσης", + "delete_call_confirm": "Διαγραφή αυτής της κλήσης; Η ενέργεια δεν μπορεί να αναιρεθεί.", + "delete_call_success": "Η κλήση διαγράφηκε", + "delete_call_error": "Αποτυχία διαγραφής κλήσης", + "reschedule": "Επαναπρογραμματισμός", + "reschedule_datetime": "Ημερομηνία και ώρα αποστολής", + "reschedule_invalid": "Εισαγάγετε έγκυρη ημερομηνία και ώρα (ΕΕΕΕ-ΜΜ-ΗΗ ΩΩ:λλ)", + "reschedule_success": "Η ώρα αποστολής ενημερώθηκε", + "reschedule_error": "Αποτυχία επαναπρογραμματισμού κλήσης", + "reschedule_in_1_hour": "Σε 1 ώρα", + "reschedule_in_1_day": "Σε 1 ημέρα", + "reschedule_tomorrow_morning": "Αύριο 8 π.μ.", + "map_call": "Κλήση", + "map_destination": "Προορισμός", + "dispatch_more": "Αποστολή πρόσθετων πόρων", + "dispatch_more_success": "Οι πόροι στάλθηκαν και ειδοποιήθηκαν", + "dispatch_more_error": "Αποτυχία αποστολής πόρων" + }, + "calls": { + "address": "Διεύθυνση", + "address_found": "Η διεύθυνση βρέθηκε και η τοποθεσία ενημερώθηκε", + "address_not_found": "Η διεύθυνση δεν βρέθηκε, δοκιμάστε διαφορετική διεύθυνση", + "address_placeholder": "Εισαγάγετε τη διεύθυνση της κλήσης", + "address_required": "Εισαγάγετε μια διεύθυνση για αναζήτηση", + "call_details": "Λεπτομέρειες Κλήσης", + "call_location": "Τοποθεσία Κλήσης", + "call_number": "Αριθμός Κλήσης", + "call_priority": "Προτεραιότητα Κλήσης", + "confirm_deselect_message": "Είστε βέβαιοι ότι θέλετε να αποεπιλέξετε την τρέχουσα ενεργή κλήση;", + "confirm_deselect_title": "Αποεπιλογή Ενεργής Κλήσης", + "contact_info": "Στοιχεία Επικοινωνίας", + "contact_info_placeholder": "Εισαγάγετε τα στοιχεία της επαφής", + "contact_name": "Όνομα Επαφής", + "contact_name_placeholder": "Εισαγάγετε το όνομα της επαφής", + "contact_phone": "Τηλέφωνο Επαφής", + "contact_phone_placeholder": "Εισαγάγετε το τηλέφωνο της επαφής", + "coordinates": "Συντεταγμένες GPS", + "coordinates_found": "Οι συντεταγμένες βρέθηκαν και η διεύθυνση ενημερώθηκε", + "coordinates_geocoding_error": "Αποτυχία λήψης διεύθυνσης για τις συντεταγμένες, αλλά η τοποθεσία ορίστηκε στον χάρτη", + "coordinates_invalid_format": "Μη έγκυρη μορφή συντεταγμένων. Χρησιμοποιήστε τη μορφή: γεωγραφικό πλάτος, γεωγραφικό μήκος", + "coordinates_no_address": "Οι συντεταγμένες ορίστηκαν στον χάρτη, αλλά δεν βρέθηκε διεύθυνση", + "coordinates_out_of_range": "Οι συντεταγμένες είναι εκτός εύρους. Το πλάτος πρέπει να είναι -90 έως 90, το μήκος -180 έως 180", + "coordinates_placeholder": "Εισαγάγετε συντεταγμένες GPS (π.χ., 37.7749, -122.4194)", + "coordinates_required": "Εισαγάγετε συντεταγμένες για αναζήτηση", + "create": "Δημιουργία", + "create_error": "Σφάλμα κατά τη δημιουργία κλήσης", + "create_new_call": "Δημιουργία Νέας Κλήσης", + "create_success": "Η κλήση δημιουργήθηκε με επιτυχία", + "description": "Περιγραφή", + "description_placeholder": "Εισαγάγετε την περιγραφή της κλήσης", + "deselect": "Αποεπιλογή", + "destination": "Προορισμός", + "destination_poi": "Σημείο Ενδιαφέροντος Προορισμού", + "directions": "Οδηγίες", + "dispatch_to": "Αποστολή Προς", + "dispatch_to_everyone": "Αποστολή σε όλο το διαθέσιμο προσωπικό", + "edit_call": "Επεξεργασία Κλήσης", + "edit_call_description": "Ενημέρωση πληροφοριών κλήσης", + "everyone": "Όλοι", + "files": { + "no_files": "Δεν υπάρχουν διαθέσιμα αρχεία", + "no_files_description": "Δεν έχουν προστεθεί ακόμη αρχεία σε αυτή την κλήση", + "title": "Αρχεία Κλήσης" + }, + "geocoding_error": "Αποτυχία αναζήτησης διεύθυνσης, δοκιμάστε ξανά", + "groups": "Ομάδες", + "invalid_priority": "Επιλέχθηκε μη έγκυρη προτεραιότητα. Επιλέξτε έγκυρη προτεραιότητα.", + "invalid_type": "Επιλέχθηκε μη έγκυρος τύπος. Επιλέξτε έγκυρο τύπο κλήσης.", + "loading": "Φόρτωση κλήσεων...", + "loading_calls": "Φόρτωση κλήσεων...", + "loading_destination_pois": "Φόρτωση σημείων ενδιαφέροντος προορισμού...", + "name": "Όνομα", + "name_placeholder": "Εισαγάγετε το όνομα της κλήσης", + "nature": "Φύση", + "nature_placeholder": "Εισαγάγετε τη φύση της κλήσης", + "new_call": "Νέα Κλήση", + "new_call_description": "Δημιουργήστε νέα κλήση για να ξεκινήσετε ένα νέο περιστατικό", + "no_call_selected": "Καμία Ενεργή Κλήση", + "no_call_selected_info": "Αυτή η μονάδα δεν ανταποκρίνεται αυτή τη στιγμή σε καμία κλήση", + "no_calls": "Καμία ενεργή κλήση", + "no_calls_available": "Δεν υπάρχουν διαθέσιμες κλήσεις", + "no_calls_description": "Δεν βρέθηκαν ενεργές κλήσεις. Επιλέξτε μια ενεργή κλήση για να δείτε λεπτομέρειες.", + "no_destination": "Χωρίς προορισμό", + "no_destination_pois_available": "Δεν υπάρχουν διαθέσιμα σημεία ενδιαφέροντος προορισμού", + "no_location_message": "Αυτή η κλήση δεν διαθέτει δεδομένα τοποθεσίας για πλοήγηση.", + "no_location_title": "Καμία Διαθέσιμη Τοποθεσία", + "no_open_calls": "Δεν υπάρχουν διαθέσιμες ανοιχτές κλήσεις", + "note": "Σημείωση", + "note_placeholder": "Εισαγάγετε τη σημείωση της κλήσης", + "plus_code": "Plus Code", + "plus_code_found": "Το plus code βρέθηκε και η τοποθεσία ενημερώθηκε", + "plus_code_geocoding_error": "Αποτυχία αναζήτησης plus code, δοκιμάστε ξανά", + "plus_code_not_found": "Το plus code δεν βρέθηκε, δοκιμάστε διαφορετικό plus code", + "plus_code_placeholder": "Εισαγάγετε plus code (π.χ., 849VCWC8+R9)", + "plus_code_required": "Εισαγάγετε ένα plus code για αναζήτηση", + "priority": "Προτεραιότητα", + "priority_placeholder": "Επιλέξτε την προτεραιότητα της κλήσης", + "roles": "Ρόλοι", + "search": "Αναζήτηση κλήσεων...", + "select_active_call": "Επιλογή Ενεργής Κλήσης", + "select_address": "Επιλογή Διεύθυνσης", + "select_address_placeholder": "Επιλέξτε τη διεύθυνση της κλήσης", + "select_description": "Επιλογή Περιγραφής", + "select_dispatch_recipients": "Επιλογή Παραληπτών Αποστολής", + "dispatch_recipients_partial_load": "Ορισμένοι παραλήπτες δεν ήταν δυνατό να φορτωθούν. Οι παρακάτω λίστες ενδέχεται να είναι ελλιπείς.", + "dispatch_recipients_empty": "Δεν είναι ορατό σε εσάς προσωπικό, ομάδες, ρόλοι ή μονάδες. Ελέγξτε τα δικαιώματα του τμήματός σας ή στείλτε σε Όλους.", + "select_destination_poi": "Επιλέξτε σημείο ενδιαφέροντος προορισμού", + "select_location": "Επιλογή Τοποθεσίας στον Χάρτη", + "select_name": "Επιλογή Ονόματος", + "select_nature": "Επιλογή Φύσης", + "select_nature_placeholder": "Επιλέξτε τη φύση της κλήσης", + "select_priority": "Επιλογή Προτεραιότητας", + "select_priority_placeholder": "Επιλέξτε την προτεραιότητα της κλήσης", + "select_recipients": "Επιλογή Παραληπτών", + "select_type": "Επιλογή Τύπου", + "selected": "επιλεγμένα", + "title": "Κλήσεις", + "type": "Τύπος", + "units": "Μονάδες", + "users": "Χρήστες", + "viewNotes": "Σημειώσεις", + "view_details": "Προβολή Λεπτομερειών", + "what3words": "what3words", + "what3words_found": "Η διεύθυνση what3words βρέθηκε και η τοποθεσία ενημερώθηκε", + "what3words_geocoding_error": "Αποτυχία αναζήτησης διεύθυνσης what3words, δοκιμάστε ξανά", + "what3words_invalid_format": "Μη έγκυρη μορφή what3words. Χρησιμοποιήστε τη μορφή: λέξη.λέξη.λέξη", + "what3words_not_found": "Η διεύθυνση what3words δεν βρέθηκε, δοκιμάστε διαφορετική διεύθυνση", + "what3words_placeholder": "Εισαγάγετε διεύθυνση what3words (π.χ., filled.count.soap)", + "what3words_required": "Εισαγάγετε μια διεύθυνση what3words για αναζήτηση", + "expand_map": "Ανάπτυξη Χάρτη", + "schedule_dispatch": "Προγραμματισμός Αποστολής", + "schedule_dispatch_description": "Ορίστε μελλοντική ημερομηνία και ώρα για να προγραμματίσετε αυτή την κλήση για μεταγενέστερη αποστολή", + "scheduled_on": "Προγραμματισμένη Ημερομηνία και Ώρα", + "scheduled_on_helper": "Αφήστε το κενό για άμεση αποστολή", + "scheduled_on_past_error": "Η προγραμματισμένη ώρα πρέπει να είναι στο μέλλον", + "contact_information": "Στοιχεία Επικοινωνίας", + "new_call_web_hint": "Συμπληρώστε τις λεπτομέρειες της κλήσης παρακάτω. Πατήστε Ctrl+Enter για δημιουργία.", + "edit_call_web_hint": "Ενημερώστε τις λεπτομέρειες της κλήσης παρακάτω. Πατήστε Ctrl+S για αποθήκευση.", + "keyboard_shortcuts": "Συμβουλή: Πατήστε Ctrl+Enter για δημιουργία, Escape για ακύρωση", + "edit_keyboard_shortcuts": "Συμβουλή: Πατήστε Ctrl+S για αποθήκευση, Escape για ακύρωση", + "templates": { + "title": "Πρότυπα Κλήσεων", + "select_template": "Επιλογή Προτύπου", + "search_placeholder": "Αναζήτηση προτύπων...", + "none": "Δεν υπάρχουν διαθέσιμα πρότυπα", + "template_applied": "Το πρότυπο εφαρμόστηκε" + }, + "form": { + "title": "Φόρμα Κλήσης", + "no_form": "Δεν έχει διαμορφωθεί φόρμα κλήσης" + }, + "linked_calls": { + "title": "Συνδεδεμένη Κλήση", + "select": "Σύνδεση με υπάρχουσα κλήση", + "change": "Αλλαγή συνδεδεμένης κλήσης", + "none": "Δεν υπάρχουν διαθέσιμες ενεργές κλήσεις", + "search_placeholder": "Αναζήτηση κλήσεων...", + "linked": "Συνδεδεμένη" + }, + "contact_picker": { + "title": "Επιλογή Επαφής", + "search_placeholder": "Αναζήτηση επαφών...", + "none": "Δεν υπάρχουν διαθέσιμες επαφές" + }, + "protocols": { + "title": "Πρωτόκολλα", + "select": "Επιλογή Πρωτοκόλλων", + "none": "Δεν υπάρχουν διαθέσιμα πρωτόκολλα", + "selected_count": "επιλεγμένα", + "expand_questions": "Προβολή ερωτήσεων", + "collapse": "Σύμπτυξη" + }, + "notify_cancelled_entities": "Ειδοποίηση ακυρωμένων οντοτήτων", + "notify_cancelled_entities_description": "Αποστολή μηνύματος στο προσωπικό/στις μονάδες που αφαιρέθηκαν από τη λίστα αποστολής.", + "audio": { + "title": "Ήχος Κλήσης", + "no_audio": "Δεν υπάρχουν ηχογραφήσεις", + "no_audio_description": "Ο ήχος που επισυνάπτεται σε αυτή την κλήση θα εμφανίζεται εδώ.", + "error": "Αποτυχία φόρτωσης ήχου", + "audio_name": "Ηχητικό απόσπασμα" + }, + "required_fields_missing": "Το τμήμα σας απαιτεί αυτά τα πεδία πριν δημιουργηθεί κλήση: {{fields}}", + "field_policy_loading": "Οι ρυθμίσεις κλήσεων του τμήματός σας φορτώνονται ακόμη, δοκιμάστε ξανά σε λίγο" + }, + "chat": { + "title": "Συνομιλία", + "assistant": "Βοηθός", + "empty": "Δεν υπάρχουν ακόμη συνομιλίες. Ξεκινήστε ένα άμεσο μήνυμα ή δημιουργήστε μια ομάδα.", + "section_assistant": "Βοηθός", + "section_direct_messages": "Άμεσα Μηνύματα", + "section_channels": "Κανάλια", + "section_incidents": "Περιστατικά", + "new_direct_message": "Νέο Άμεσο Μήνυμα", + "new_group": "Νέα Ομάδα", + "open_assistant": "Άνοιγμα Βοηθού", + "create_conversation_failed": "Δεν ήταν δυνατή η έναρξη της συνομιλίας", + "group_name": "Όνομα ομάδας", + "group_name_optional": "Όνομα ομάδας (προαιρετικό)", + "unit": "Μονάδα", + "search_people": "Αναζήτηση ατόμων", + "no_people": "Δεν βρέθηκαν άτομα", + "create_group_with": "Δημιουργία ομάδας ({{count}})", + "message_deleted": "Αυτό το μήνυμα διαγράφηκε", + "urgent": "Επείγον", + "urgent_will_send": "Αυτό το μήνυμα θα σταλεί ως επείγον", + "shared_location": "Κοινοποιημένη τοποθεσία", + "thread_replies": "{{count}} απαντήσεις", + "edited": "(επεξεργάστηκε)", + "failed_tap_retry": "Απέτυχε - πατήστε για επανάληψη", + "type_a_message": "Πληκτρολογήστε ένα μήνυμα", + "emoji": "Emoji", + "add_image": "Προσθήκη εικόνας", + "add_gif": "Προσθήκη GIF", + "share_location": "Κοινή χρήση τοποθεσίας", + "send": "Αποστολή", + "someone": "Κάποιος", + "is_typing": "Ο/Η {{name}} πληκτρολογεί...", + "are_typing": "{{count}} άτομα πληκτρολογούν...", + "permission_photos_denied": "Η άδεια βιβλιοθήκης φωτογραφιών απορρίφθηκε", + "permission_location_denied": "Η άδεια τοποθεσίας απορρίφθηκε", + "search_gifs": "Αναζήτηση GIF", + "no_gifs": "Δεν βρέθηκαν GIF", + "flag_reason": "Γιατί το αναφέρετε αυτό;", + "flag_inappropriate": "Ακατάλληλο", + "flag_harassment": "Παρενόχληση", + "flag_spam": "Ανεπιθύμητο", + "flag_sensitive": "Ευαίσθητες πληροφορίες", + "flag_policy": "Παραβίαση πολιτικής", + "flag_other": "Άλλο", + "reply_in_thread": "Απάντηση στο νήμα", + "copy": "Αντιγραφή", + "copied": "Αντιγράφηκε", + "copy_unavailable": "Η αντιγραφή δεν είναι διαθέσιμη σε αυτή τη συσκευή", + "edit": "Επεξεργασία", + "edit_message": "Επεξεργασία μηνύματος", + "save": "Αποθήκευση", + "delete": "Διαγραφή", + "pin": "Καρφίτσωμα", + "unpin": "Ξεκαρφίτσωμα", + "flag": "Αναφορά", + "moderator_delete": "Αφαίρεση (συντονιστής)", + "moderator_removed": "Αφαιρέθηκε από συντονιστή", + "attachment_failed": "Η μεταφόρτωση του συνημμένου απέτυχε", + "ack_required": "Απαιτείται επιβεβαίωση", + "ack_pending_one": "Έχετε ένα επείγον μήνυμα προς επιβεβαίωση", + "ack_pending_count": "Έχετε {{count}} επείγοντα μηνύματα προς επιβεβαίωση", + "acknowledge": "Επιβεβαίωση", + "thread": "Νήμα", + "original_message": "Αρχικό μήνυμα", + "reply_placeholder": "Απάντηση...", + "channel": "Κανάλι", + "direct_message": "Άμεσο Μήνυμα", + "load_people_failed": "Δεν ήταν δυνατή η φόρτωση ατόμων", + "reaction_failed": "Δεν ήταν δυνατή η ενημέρωση της αντίδρασης", + "edit_failed": "Δεν ήταν δυνατή η επεξεργασία του μηνύματος", + "delete_failed": "Δεν ήταν δυνατή η διαγραφή του μηνύματος", + "pin_failed": "Δεν ήταν δυνατή η ενημέρωση του καρφιτσώματος", + "flag_failed": "Δεν ήταν δυνατή η αναφορά του μηνύματος" + }, + "chatbot": { + "title": "Βοηθός", + "subtitle": "Βοηθός AI για το τμήμα σας", + "new_session": "Νέα συνεδρία", + "empty": "Ρωτήστε τον βοηθό οτιδήποτε για να ξεκινήσετε.", + "ask_placeholder": "Ρωτήστε τον βοηθό..." + }, + "check_in": { + "tab_title": "Παρουσία", + "timer_status": "Κατάσταση Χρονομέτρου", + "perform_check_in": "Δήλωση Παρουσίας", + "check_in_success": "Η παρουσία καταγράφηκε με επιτυχία", + "check_in_error": "Αποτυχία καταγραφής παρουσίας", + "checked_in_by": "από {{name}}", + "last_check_in": "Τελευταία παρουσία", + "elapsed": "Παρήλθαν", + "duration": "Διάρκεια", + "status_ok": "Εντάξει", + "status_green": "Εντάξει", + "status_warning": "Προειδοποίηση", + "status_yellow": "Προειδοποίηση", + "status_overdue": "Εκπρόθεσμο", + "status_red": "Εκπρόθεσμο", + "status_critical": "Κρίσιμο", + "history": "Ιστορικό Παρουσιών", + "no_timers": "Δεν έχουν διαμορφωθεί χρονόμετρα παρουσίας", + "timers_disabled": "Τα χρονόμετρα παρουσίας είναι απενεργοποιημένα για αυτή την κλήση", + "type_personnel": "Προσωπικό", + "type_unit": "Μονάδα", + "type_ic": "Διοικητής Συμβάντος", + "type_par": "PAR", + "type_hazmat": "Έκθεση σε Επικίνδυνα Υλικά", + "type_sector_rotation": "Εναλλαγή Τομέα", + "type_rehab": "Αποκατάσταση", + "add_note": "Προσθήκη Σημείωσης (Προαιρετικό)", + "confirm": "Επιβεβαίωση Παρουσίας", + "minutes_ago": "{{count}} λεπ. πριν", + "select_target": "Επιλέξτε Οντότητα για Δήλωση Παρουσίας", + "overdue_count": "{{count}} Εκπρόθεσμα", + "warning_count": "{{count}} Προειδοποιήσεις", + "enable_timers": "Ενεργοποίηση Χρονομέτρων", + "disable_timers": "Απενεργοποίηση Χρονομέτρων", + "summary": "{{overdue}} εκπρόθεσμα, {{warning}} προειδοποιήσεις, {{ok}} εντάξει", + "par_title": "Λογοδοσία Προσωπικού (PAR)" + }, + "common": { + "add": "Προσθήκη", + "back": "Πίσω", + "cancel": "Ακύρωση", + "creating": "Δημιουργία...", + "saving": "Αποθήκευση...", + "unsaved_changes": "Μη αποθηκευμένες αλλαγές", + "close": "Κλείσιμο", + "confirm": "Επιβεβαίωση", + "confirm_location": "Επιβεβαίωση Τοποθεσίας", + "delete": "Διαγραφή", + "dismiss": "Κλείσιμο", + "done": "Ολοκληρώθηκε", + "edit": "Επεξεργασία", + "error": "Σφάλμα", + "errorOccurred": "Παρουσιάστηκε σφάλμα", + "get_my_location": "Λήψη Τοποθεσίας Μου", + "go_back": "Επιστροφή", + "loading": "Φόρτωση...", + "loading_address": "Φόρτωση διεύθυνσης...", + "next": "Επόμενο", + "noActiveUnit": "Δεν Έχει Οριστεί Ενεργή Μονάδα", + "noActiveUnitDescription": "Ορίστε μια ενεργή μονάδα από τη σελίδα ρυθμίσεων για πρόσβαση στα στοιχεία ελέγχου κατάστασης", + "noDataAvailable": "Δεν υπάρχουν διαθέσιμα δεδομένα", + "no_address_found": "Δεν βρέθηκε διεύθυνση", + "no_location": "Δεν υπάρχουν διαθέσιμα δεδομένα τοποθεσίας", + "no_results_found": "Δεν βρέθηκαν αποτελέσματα", + "no_unit_selected": "Δεν Επιλέχθηκε Μονάδα", + "nothingToDisplay": "Δεν υπάρχει τίποτα να εμφανιστεί αυτή τη στιγμή", + "of": "από", + "ok": "Εντάξει", + "optional": "προαιρετικό", + "permission_denied": "Η άδεια απορρίφθηκε", + "previous": "Προηγούμενο", + "remove": "Αφαίρεση", + "retry": "Επανάληψη", + "route": "Διαδρομή", + "save": "Αποθήκευση", + "search": "Αναζήτηση...", + "set_location": "Ορισμός Τοποθεσίας", + "share": "Κοινή χρήση", + "step": "Βήμα", + "submit": "Υποβολή", + "submitting": "Υποβολή...", + "tryAgainLater": "Δοκιμάστε ξανά αργότερα", + "unknown": "Άγνωστο", + "unknown_department": "Άγνωστο Τμήμα", + "unknown_user": "Άγνωστος Χρήστης", + "upload": "Μεταφόρτωση", + "uploading": "Μεταφόρτωση..." + }, + "contacts": { + "add": "Προσθήκη Επαφής", + "addedBy": "Προστέθηκε Από", + "addedOn": "Προστέθηκε Στις", + "additionalInformation": "Πρόσθετες Πληροφορίες", + "address": "Διεύθυνση", + "bluesky": "Bluesky", + "cancel": "Ακύρωση", + "cellPhone": "Κινητό Τηλέφωνο", + "city": "Πόλη", + "cityState": "Πόλη και Νομός", + "cityStateZip": "Πόλη, Νομός, Τ.Κ.", + "company": "Εταιρεία", + "contactInformation": "Στοιχεία Επικοινωνίας", + "contactNotes": "Σημειώσεις Επαφής", + "contactNotesEmpty": "Δεν βρέθηκαν σημειώσεις για αυτή την επαφή", + "contactNotesEmptyDescription": "Οι σημειώσεις που προστίθενται σε αυτή την επαφή θα εμφανίζονται εδώ", + "contactNotesExpired": "Αυτή η σημείωση έχει λήξει", + "contactNotesLoading": "Φόρτωση σημειώσεων επαφής...", + "contactType": "Τύπος Επαφής", + "countryId": "Αναγνωριστικό Χώρας", + "delete": "Διαγραφή", + "deleteConfirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτή την επαφή;", + "deleteSuccess": "Η επαφή διαγράφηκε με επιτυχία", + "description": "Προσθέστε και διαχειριστείτε τις επαφές σας", + "details": "Λεπτομέρειες Επαφής", + "detailsTab": "Λεπτομέρειες", + "edit": "Επεξεργασία Επαφής", + "editedBy": "Επεξεργάστηκε Από", + "editedOn": "Επεξεργάστηκε Στις", + "email": "Email", + "empty": "Δεν βρέθηκαν επαφές", + "emptyDescription": "Προσθέστε επαφές για να διαχειριστείτε τις προσωπικές και επαγγελματικές σας γνωριμίες", + "entranceCoordinates": "Συντεταγμένες Εισόδου", + "exitCoordinates": "Συντεταγμένες Εξόδου", + "expires": "Λήγει", + "facebook": "Facebook", + "faxPhone": "Φαξ", + "formError": "Διορθώστε τα σφάλματα στη φόρμα", + "homePhone": "Τηλέφωνο Οικίας", + "identification": "Ταυτοποίηση", + "important": "Σήμανση ως Σημαντική", + "instagram": "Instagram", + "internal": "Εσωτερική", + "invalidEmail": "Μη έγκυρη διεύθυνση email", + "linkedin": "LinkedIn", + "locationCoordinates": "Συντεταγμένες Τοποθεσίας", + "locationInformation": "Πληροφορίες Τοποθεσίας", + "mastodon": "Mastodon", + "mobile": "Κινητό", + "name": "Όνομα", + "noteAlert": "Ειδοποίηση", + "noteType": "Τύπος", + "notes": "Σημειώσεις", + "notesTab": "Σημειώσεις", + "officePhone": "Τηλέφωνο Γραφείου", + "otherInfo": "Άλλες Πληροφορίες", + "person": "Άτομο", + "phone": "Τηλέφωνο", + "public": "Δημόσια", + "required": "Απαιτείται", + "save": "Αποθήκευση Επαφής", + "saveSuccess": "Η επαφή αποθηκεύτηκε με επιτυχία", + "search": "Αναζήτηση επαφών...", + "shouldAlert": "Να Ειδοποιεί", + "socialMediaWeb": "Κοινωνικά Δίκτυα και Web", + "state": "Νομός", + "stateId": "Αναγνωριστικό Νομού", + "systemInformation": "Πληροφορίες Συστήματος", + "tabs": { + "details": "Λεπτομέρειες", + "notes": "Σημειώσεις" + }, + "threads": "Threads", + "title": "Επαφές", + "twitter": "Twitter", + "visibility": "Ορατότητα", + "website": "Ιστότοπος", + "zip": "Ταχυδρομικός Κώδικας" + }, + "dispatch": { + "active_calls": "Ενεργές Κλήσεις", + "pending_calls": "Εκκρεμείς", + "scheduled_calls": "Προγραμματισμένες", + "units_available": "Διαθέσιμες", + "personnel_available": "Διαθέσιμο", + "personnel_on_duty": "Σε Υπηρεσία", + "units": "Μονάδες", + "personnel": "Προσωπικό", + "map": "Χάρτης", + "notes": "Σημειώσεις", + "activity_log": "Αρχείο Δραστηριότητας", + "communications": "Επικοινωνίες", + "no_active_calls": "Καμία ενεργή κλήση", + "no_units": "Δεν υπάρχουν διαθέσιμες μονάδες", + "no_personnel": "Δεν υπάρχει διαθέσιμο προσωπικό", + "no_notes": "Δεν υπάρχουν διαθέσιμες σημειώσεις", + "no_activity": "Καμία πρόσφατη δραστηριότητα", + "current_channel": "Τρέχον Κανάλι", + "audio_stream": "Ροή Ήχου", + "no_stream": "Καμία ενεργή ροή", + "ptt": "PTT", + "ptt_start": "Έναρξη PTT", + "ptt_end": "Λήξη PTT", + "transmitting_on": "Εκπομπή στο {{channel}}", + "transmission_ended": "Η εκπομπή τερματίστηκε", + "voice_disabled": "Η φωνή είναι απενεργοποιημένη", + "disconnected": "Αποσυνδέθηκε", + "select_channel": "Επιλογή Καναλιού", + "select_channel_description": "Επιλέξτε φωνητικό κανάλι για σύνδεση", + "change_channel_warning": "Η επιλογή νέου καναλιού θα σας αποσυνδέσει από το τρέχον", + "default_channel": "Προεπιλεγμένο", + "no_channels_available": "Δεν υπάρχουν διαθέσιμα φωνητικά κανάλια", + "system_update": "Ενημέρωση Συστήματος", + "data_refreshed": "Τα δεδομένα ανανεώθηκαν από τον διακομιστή", + "call_selected": "Επιλέχθηκε Κλήση", + "unit_selected": "Επιλέχθηκε Μονάδα", + "unit_deselected": "Αποεπιλέχθηκε Μονάδα", + "personnel_selected": "Επιλέχθηκε Προσωπικό", + "personnel_deselected": "Αποεπιλέχθηκε Προσωπικό", + "loading_map": "Φόρτωση χάρτη...", + "map_not_available_web": "Ο χάρτης δεν είναι διαθέσιμος στην πλατφόρμα web", + "filtering_by_call": "Φιλτράρισμα κατά κλήση", + "clear_filter": "Καθαρισμός Φίλτρου", + "call_filter_active": "Ενεργό Φίλτρο Κλήσης", + "call_filter_cleared": "Το Φίλτρο Κλήσης Καθαρίστηκε", + "showing_all_data": "Εμφάνιση όλων των δεδομένων", + "call_notes": "Σημειώσεις Κλήσης", + "no_call_notes": "Δεν υπάρχουν σημειώσεις κλήσης", + "add_call_note_placeholder": "Προσθέστε μια σημείωση...", + "note_added": "Η Σημείωση Προστέθηκε", + "note_added_to_console": "Μια νέα σημείωση προστέθηκε στην κονσόλα", + "add_note_title": "Προσθήκη Νέας Σημείωσης", + "note_title_label": "Τίτλος", + "note_title_placeholder": "Εισαγάγετε τίτλο σημείωσης...", + "note_category_label": "Κατηγορία", + "note_category_placeholder": "Επιλέξτε κατηγορία", + "note_no_category": "Χωρίς Κατηγορία", + "note_body_label": "Περιεχόμενο Σημείωσης", + "note_body_placeholder": "Εισαγάγετε περιεχόμενο σημείωσης...", + "note_save_error": "Αποτυχία αποθήκευσης σημείωσης: {{error}}", + "note_created": "Η Σημείωση Δημιουργήθηκε", + "units_on_call": "Μονάδες στην Κλήση", + "no_units_on_call": "Καμία μονάδα στην κλήση", + "personnel_on_call": "Προσωπικό στην Κλήση", + "no_personnel_on_call": "Κανένα προσωπικό στην κλήση", + "call_activity": "Δραστηριότητα Κλήσης", + "no_call_activity": "Καμία δραστηριότητα κλήσης", + "on_call": "Στην Κλήση", + "filtered": "Φιλτραρισμένα", + "active_filter": "Ενεργό Φίλτρο", + "unit_status_change": "Αλλαγή Κατάστασης Μονάδας", + "personnel_status_change": "Αλλαγή Κατάστασης Προσωπικού", + "view_call_details": "Προβολή Λεπτομερειών Κλήσης", + "dispatched_resources": "Απεσταλμένοι", + "unassigned": "Χωρίς ανάθεση", + "available": "Διαθέσιμος", + "unknown": "Άγνωστο", + "search_personnel_placeholder": "Αναζήτηση προσωπικού...", + "search_calls_placeholder": "Αναζήτηση κλήσεων...", + "search_units_placeholder": "Αναζήτηση μονάδων...", + "search_notes_placeholder": "Αναζήτηση σημειώσεων...", + "signalr_update": "Ενημέρωση σε Πραγματικό Χρόνο", + "signalr_connected": "Συνδεδεμένο", + "realtime_updates_active": "Οι ενημερώσεις πραγματικού χρόνου είναι πλέον ενεργές", + "personnel_status_updated": "Η κατάσταση προσωπικού ενημερώθηκε", + "personnel_staffing_updated": "Η στελέχωση προσωπικού ενημερώθηκε", + "unit_status_updated": "Η κατάσταση μονάδας ενημερώθηκε", + "calls_updated": "Οι κλήσεις ενημερώθηκαν", + "call_added": "Προστέθηκε νέα κλήση", + "call_closed": "Η κλήση έκλεισε", + "check_ins": "Δηλώσεις Παρουσίας", + "no_check_ins": "Καμία κλήση με χρονόμετρα παρουσίας", + "radio_log": "Αρχείο Ασυρμάτου", + "radio": "Ασύρματος", + "activity": "Δραστηριότητα", + "actions": "Ενέργειες", + "no_radio_activity": "Καμία εκπομπή ασυρμάτου", + "live": "ΖΩΝΤΑΝΑ", + "currently_transmitting": "Εκπέμπει αυτή τη στιγμή...", + "duration": "Διάρκεια", + "call_actions": "Ενέργειες Κλήσης", + "unit_actions": "Ενέργειες Μονάδας", + "personnel_actions": { + "title": "Ενέργειες Προσωπικού", + "status_tab": "Κατάσταση", + "staffing_tab": "Στελέχωση", + "select_status": "Επιλογή Κατάστασης", + "select_staffing": "Επιλογή Επιπέδου Στελέχωσης", + "destination": "Προορισμός", + "no_destination": "Χωρίς Προορισμό", + "note": "Σημείωση", + "note_placeholder": "Προσθέστε μια προαιρετική σημείωση...", + "update_status": "Ενημέρωση Κατάστασης", + "update_staffing": "Ενημέρωση Στελέχωσης", + "no_statuses_available": "Δεν υπάρχουν διαθέσιμες καταστάσεις", + "no_staffings_available": "Δεν υπάρχουν διαθέσιμα επίπεδα στελέχωσης" + }, + "unit_actions_panel": { + "status": "Κατάσταση", + "select_status": "Επιλογή Κατάστασης", + "destination": "Προορισμός", + "no_destination": "Χωρίς Προορισμό", + "note": "Σημείωση", + "note_placeholder": "Προσθέστε μια προαιρετική σημείωση...", + "update_status": "Ενημέρωση Κατάστασης", + "no_statuses_available": "Δεν υπάρχουν διαθέσιμες καταστάσεις", + "no_active_calls": "Καμία ενεργή κλήση", + "no_stations_available": "Δεν υπάρχουν διαθέσιμοι σταθμοί", + "no_destinations_available": "Δεν υπάρχουν διαθέσιμοι προορισμοί" + }, + "call": "Κλήση", + "station": "Σταθμός", + "calls": "Κλήσεις", + "stations": "Σταθμοί", + "no_stations_available": "Δεν υπάρχουν διαθέσιμοι σταθμοί", + "new_call": "Νέα Κλήση", + "view_details": "Λεπτομέρειες", + "add_note": "Προσθήκη Σημείωσης", + "close_call": "Κλείσιμο", + "set_status": "Ορισμός Κατάστασης", + "set_staffing": "Στελέχωση", + "dispatch": "Αποστολή", + "select_items_for_actions": "Επιλέξτε κλήση, μονάδα ή προσωπικό για να ενεργοποιήσετε ενέργειες σχετικές με το περιεχόμενο", + "weather": { + "clear": "Αίθριος", + "mainly_clear": "Κυρίως Αίθριος", + "partly_cloudy": "Μερικώς Νεφελώδης", + "overcast": "Συννεφιά", + "fog": "Ομίχλη", + "drizzle": "Ψιχάλα", + "freezing_drizzle": "Παγωμένη Ψιχάλα", + "rain": "Βροχή", + "freezing_rain": "Παγωμένη Βροχή", + "snow": "Χιόνι", + "rain_showers": "Μπόρες", + "snow_showers": "Χιονοπτώσεις", + "thunderstorm": "Καταιγίδα", + "thunderstorm_hail": "Καταιγίδα με Χαλάζι", + "unknown": "Άγνωστος" + }, + "available_only": "Μόνο διαθέσιμοι", + "single_list": "Ενιαία λίστα", + "resources": "Πόροι", + "search_resources_placeholder": "Αναζήτηση πόρων...", + "no_resources": "Δεν υπάρχουν πόροι" + }, + "form": { + "invalid_url": "Εισαγάγετε έγκυρη διεύθυνση URL που ξεκινά με https:// (το http:// επιτρέπεται μόνο για localhost)", + "required": "Αυτό το πεδίο είναι υποχρεωτικό" + }, + "incident_command": { + "accountability": "Λογοδοσία (PAR)", + "acknowledge": "Επιβεβαίωση", + "action_plan": "Σχέδιο Δράσης", + "action_plan_placeholder": "Περιγράψτε το σχέδιο δράσης του περιστατικού...", + "active": "Ενεργή", + "active_title": "Ενεργές Διοικήσεις Περιστατικών", + "add": "Προσθήκη", + "add_channel": "Προσθήκη Καναλιού", + "add_lane": "Προσθήκη Τομέα", + "add_marker": "Προσθήκη Δείκτη", + "add_objective": "Προσθήκη Στόχου", + "annotations": "Σχολιασμοί Χάρτη", + "assign": "Ανάθεση", + "assign_resource": "Ανάθεση Πόρου", + "assign_resource_required": "Επιλέξτε τομέα και πόρο", + "assign_role": "Ανάθεση Ρόλου", + "assign_role_required": "Επιλέξτε άτομο και ρόλο", + "call": "Κλήση", + "channel_name": "Όνομα καναλιού", + "chat": "Συνομιλία", + "chat_frozen": "Αυτό το περιστατικό έχει κλείσει. Οι συνομιλίες διατηρούνται ως αρχείο δεδομένης χρονικής στιγμής — δεν επιτρέπονται νέα μηνύματα.", + "close_all_channels": "Κλείσιμο όλων των καναλιών", + "close_command": "Κλείσιμο Διοίκησης", + "closed": "Έκλεισε", + "command_channel": "Συνομιλία διοίκησης", + "command_channel_hint": "Επιτελείο διοίκησης και κέντρο επιχειρήσεων", + "command_channel_unavailable": "Δεν έχει δημιουργηθεί ακόμη κανάλι διοίκησης για αυτό το περιστατικό.", + "commander": "Διοικητής", + "complete": "Ολοκλήρωση", + "completed": "Ολοκληρώθηκε", + "confirm_close": "Κλείσιμο της διοίκησης περιστατικού για αυτή την κλήση;", + "critical": "Κρίσιμο", + "delete_annotation_confirm": "Αφαίρεση αυτού του σχολιασμού;", + "dispatch_channel": "Κέντρο Επιχειρήσεων", + "dispatch_channel_hint": "Η γραμμή του περιστατικού προς το κέντρο", + "dispatch_channel_unavailable": "Δεν έχει δημιουργηθεί ακόμη κανάλι κέντρου επιχειρήσεων για αυτό το περιστατικό.", + "dm_failed": "Δεν ήταν δυνατό το άνοιγμα αυτής της συνομιλίας.", + "dm_unavailable": "Αυτή η επαφή δεν έχει λογαριασμό Resgrid για αποστολή μηνύματος.", + "due": "Προθεσμία", + "edit": "Επεξεργασία", + "edit_action_plan": "Επεξεργασία Σχεδίου Δράσης", + "establish": "Εγκατάσταση Διοίκησης", + "establish_description": "Προαιρετικά, αρχικοποιήστε τον πίνακα διοίκησης από πρότυπο.", + "establish_error": "Αποτυχία εγκατάστασης διοίκησης", + "establish_success": "Η διοίκηση περιστατικού εγκαταστάθηκε", + "establish_title": "Εγκατάσταση Διοίκησης Περιστατικού", + "established_on": "Εγκαταστάθηκε", + "green": "Πράσινο", + "hold_to_talk": "Κρατήστε για Ομιλία", + "incident_channel": "Συνομιλία περιστατικού", + "incident_channel_hint": "Όλοι όσοι εργάζονται στο περιστατικό", + "incident_channel_unavailable": "Δεν έχει δημιουργηθεί ακόμη κανάλι περιστατικού για αυτή την κλήση.", + "join": "Συμμετοχή", + "lane": "Τομέας", + "lane_name": "Όνομα Τομέα", + "lane_type": "Τύπος Τομέα", + "marker": "Δείκτης", + "marker_label": "Ετικέτα δείκτη", + "move": "Μετακίνηση", + "move_lane": "Μετακίνηση Τομέα", + "move_resource": "Μετακίνηση Πόρου", + "name_required": "Το όνομα είναι υποχρεωτικό", + "no_accountability": "Δεν παρακολουθείται προσωπικό.", + "no_action_plan": "Δεν έχει οριστεί σχέδιο δράσης.", + "no_active": "Καμία Ενεργή Διοίκηση Περιστατικού", + "no_active_description": "Οι διοικήσεις περιστατικών που εγκαθίστανται σε κλήσεις θα εμφανίζονται εδώ.", + "no_annotations": "Χωρίς σχολιασμούς.", + "no_channels": "Κανένα ανοιχτό κανάλι.", + "no_command": "Δεν Έχει Εγκατασταθεί Διοίκηση Περιστατικού", + "no_command_description": "Εγκαταστήστε διοίκηση περιστατικού για να συντονίσετε πόρους, ρόλους, στόχους και λογοδοσία για αυτή την κλήση.", + "no_lanes": "Δεν έχουν οριστεί τομείς.", + "no_objectives": "Χωρίς στόχους.", + "no_resources": "Δεν έχουν ανατεθεί πόροι.", + "no_roles": "Δεν έχουν ανατεθεί ρόλοι.", + "no_template": "Χωρίς πρότυπο (κενός πίνακας)", + "no_timeline": "Χωρίς καταχωρίσεις χρονολογίου.", + "no_timers": "Δεν εκτελούνται χρονόμετρα.", + "not_authorized": "Ο πίνακας διοίκησης δεν είναι διαθέσιμος", + "not_authorized_description": "Το τμήμα σας δεν σας έχει εξουσιοδοτήσει να εργάζεστε στη διοίκηση περιστατικών. Ζητήστε από έναν διαχειριστή την άδεια Σύνδεσης στην Εφαρμογή Διοίκησης.", + "objective_name": "Στόχος", + "objective_type": "Τύπος", + "objectives": "Στόχοι", + "open_chat": "Άνοιγμα", + "open_full_board": "Άνοιγμα Πλήρους Πίνακα", + "open_tactical_map": "Άνοιγμα τακτικού χάρτη", + "parent_lane": "Γονικός τομέας", + "person": "Άτομο", + "personnel": "Προσωπικό", + "release": "Αποδέσμευση", + "resource": "Πόρος", + "resource_type": "Τύπος Πόρου", + "role": "Ρόλος", + "roles": "Ρόλοι Διοίκησης", + "run_par": "Εκτέλεση PAR", + "save": "Αποθήκευση", + "save_error": "Η ενέργεια απέτυχε", + "saved": "Αποθηκεύτηκε", + "select_lane": "Επιλέξτε τομέα", + "select_person": "Επιλέξτε άτομο", + "select_resource": "Επιλέξτε πόρο", + "select_role": "Επιλέξτε ρόλο", + "send_message": "Μήνυμα", + "status": "Κατάσταση", + "structure": "Δομή Διοίκησης", + "tab_title": "Διοίκηση", + "tactical_map": "Τακτικός Χάρτης", + "talking": "Εκπομπή...", + "tap_to_place": "Πατήστε στον χάρτη για να τοποθετήσετε δείκτη", + "template": "Πρότυπο", + "timeline": "Χρονολόγιο Διοίκησης", + "timers": "Χρονόμετρα", + "title": "Διοίκηση Περιστατικού", + "top_level": "Ανώτατο επίπεδο", + "transfer": "Μεταβίβαση", + "transfer_command": "Μεταβίβαση", + "transfer_notes": "Σημειώσεις", + "transfer_success": "Η διοίκηση μεταβιβάστηκε", + "transfer_title": "Μεταβίβαση Διοίκησης", + "unassigned": "Χωρίς ανάθεση", + "unit": "Μονάδα", + "voice_channels": "Φωνητικά Κανάλια", + "voice_join_error": "Αποτυχία συμμετοχής στο φωνητικό κανάλι", + "voice_joined": "Συμμετοχή στο φωνητικό κανάλι", + "warning": "Προειδοποίηση" + }, + "livekit": { + "audio_devices": "Συσκευές Ήχου", + "audio_settings": "Ρυθμίσεις Ήχου", + "connected_to_room": "Συνδεδεμένος στο Κανάλι", + "connecting": "Σύνδεση...", + "disconnect": "Αποσύνδεση", + "join": "Συμμετοχή", + "microphone": "Μικρόφωνο", + "mute": "Σίγαση", + "no_rooms_available": "Δεν υπάρχουν διαθέσιμα φωνητικά κανάλια", + "speaker": "Ηχείο", + "speaking": "Ομιλία", + "title": "Φωνητικά Κανάλια", + "unmute": "Κατάργηση σίγασης" + }, + "loading": { + "loading": "Φόρτωση...", + "loadingData": "Φόρτωση δεδομένων...", + "pleaseWait": "Παρακαλώ περιμένετε", + "processingRequest": "Επεξεργασία του αιτήματός σας..." + }, + "lockscreen": { + "message": "Εισαγάγετε τον κωδικό πρόσβασής σας για να ξεκλειδώσετε την οθόνη", + "not_you": "Δεν είστε εσείς; Επιστροφή στη σύνδεση", + "password": "Κωδικός Πρόσβασης", + "password_placeholder": "Εισαγάγετε τον κωδικό πρόσβασής σας", + "title": "Οθόνη Κλειδώματος", + "unlock_button": "Ξεκλείδωμα", + "unlock_failed": "Αποτυχία ξεκλειδώματος. Δοκιμάστε ξανά.", + "relogin_required": "Η επαλήθευση κωδικού πρόσβασης δεν είναι διαθέσιμη για αυτή τη συνεδρία. Συνδεθείτε ξανά.", + "unlocking": "Ξεκλείδωμα...", + "welcome_back": "Καλώς Ήρθατε Ξανά" + }, + "login": { + "branding_subtitle": "Ισχυρό λογισμικό διαβίβασης για πρώτους ανταποκριτές, ομάδες έρευνας και διάσωσης και οργανισμούς δημόσιας ασφάλειας.", + "branding_title": "Διαχείριση Ανταπόκρισης Έκτακτης Ανάγκης", + "dispatch_not_authorized": "Δεν είστε εξουσιοδοτημένοι να χρησιμοποιήσετε την εφαρμογή Dispatch. Επικοινωνήστε με τον διαχειριστή του τμήματός σας.", + "errorModal": { + "confirmButton": "Εντάξει", + "message": "Ελέγξτε το όνομα χρήστη και τον κωδικό πρόσβασής σας και δοκιμάστε ξανά.", + "title": "Η Σύνδεση Απέτυχε" + }, + "feature_dispatch_desc": "Στείλτε άμεσα μονάδες και διαχειριστείτε κλήσεις με ζωντανές ενημερώσεις σε όλες τις συσκευές.", + "feature_dispatch_title": "Αποστολή σε Πραγματικό Χρόνο", + "feature_mapping_desc": "Παρακολουθήστε μονάδες σε πραγματικό χρόνο με λεπτομερείς χάρτες, δρομολόγηση και διαχείριση τοποθεσίας.", + "feature_mapping_title": "Προηγμένη Χαρτογράφηση", + "feature_personnel_desc": "Διαχειριστείτε την ομάδα σας με πρόσβαση βάσει ρόλων, παρακολούθηση κατάστασης και εργαλεία επικοινωνίας.", + "feature_personnel_title": "Διαχείριση Προσωπικού", + "footer_text": "Δημιουργήθηκε με ❤️ στη Λίμνη Tahoe", + "login": "Σύνδεση", + "login_button": "Σύνδεση", + "login_button_description": "Συνδεθείτε στον λογαριασμό σας για να συνεχίσετε", + "login_button_error": "Σφάλμα κατά τη σύνδεση", + "login_button_loading": "Σύνδεση...", + "login_button_success": "Επιτυχής σύνδεση", + "no_account": "Δεν έχετε λογαριασμό;", + "page_subtitle": "Εισαγάγετε τα διαπιστευτήριά σας για να συνδεθείτε.", + "page_title": "Resgrid Dispatch", + "password": "Κωδικός Πρόσβασης", + "password_incorrect": "Ο κωδικός πρόσβασης ήταν λανθασμένος", + "password_placeholder": "Εισαγάγετε τον κωδικό πρόσβασής σας", + "register": "Εγγραφή", + "title": "Σύνδεση", + "username": "Όνομα Χρήστη", + "username_placeholder": "Εισαγάγετε το όνομα χρήστη σας", + "welcome_title": "Καλώς Ήρθατε Ξανά" + }, + "maintenance": { + "downtime_message": "Εργαζόμαστε εντατικά για να ολοκληρώσουμε τη συντήρηση όσο το δυνατόν γρηγορότερα. Ελέγξτε ξανά σύντομα.", + "downtime_title": "Τι είναι ο Χρόνος Διακοπής;", + "message": "Ελέγξτε ξανά σε λίγο.", + "support_message": "Αν χρειάζεστε βοήθεια, επικοινωνήστε μαζί μας στο", + "support_title": "Χρειάζεστε Υποστήριξη;", + "title": "Ο Ιστότοπος Βρίσκεται σε Συντήρηση", + "why_down_message": "Πραγματοποιούμε προγραμματισμένη συντήρηση για να βελτιώσουμε την εμπειρία σας. Ζητούμε συγγνώμη για την όποια αναστάτωση.", + "why_down_title": "Γιατί ο Ιστότοπος Είναι Εκτός Λειτουργίας;" + }, + "map": { + "call_set_as_current": "Η κλήση ορίστηκε ως τρέχουσα κλήση", + "failed_to_open_maps": "Αποτυχία ανοίγματος εφαρμογής χαρτών", + "failed_to_set_current_call": "Αποτυχία ορισμού της κλήσης ως τρέχουσας", + "layers": "Επίπεδα Χάρτη", + "no_layers": "Δεν υπάρχουν διαθέσιμα επίπεδα", + "no_location_for_routing": "Δεν υπάρχουν διαθέσιμα δεδομένα τοποθεσίας για δρομολόγηση", + "pin_color": "Χρώμα Πινέζας", + "recenter_map": "Επανακεντράρισμα Χάρτη", + "set_as_current_call": "Ορισμός ως Τρέχουσα Κλήση", + "show_all": "Εμφάνιση Όλων", + "hide_all": "Απόκρυψη Όλων", + "view_call_details": "Προβολή Λεπτομερειών Κλήσης", + "view_poi_details": "Προβολή Λεπτομερειών Σημείου Ενδιαφέροντος" + }, + "menu": { + "calls": "Κλήσεις", + "calls_list": "Λίστα Κλήσεων", + "scheduled_calls": "Προγραμματισμένες Κλήσεις", + "contacts": "Επαφές", + "home": "Αρχική", + "map": "Χάρτης", + "menu": "Μενού", + "messages": "Μηνύματα", + "new_call": "Νέα Κλήση", + "personnel": "Προσωπικό", + "pois": "Σημεία Ενδιαφέροντος", + "protocols": "Πρωτόκολλα", + "settings": "Ρυθμίσεις", + "units": "Μονάδες", + "weatherAlerts": "Καιρικές Ειδοποιήσεις", + "incident_command": "Διοίκηση Περιστατικού", + "chat": "Συνομιλία", + "assistant": "Βοηθός" + }, + "notes": { + "actions": { + "add": "Προσθήκη Σημείωσης", + "delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτή τη σημείωση;" + }, + "details": { + "close": "Κλείσιμο", + "created": "Δημιουργήθηκε", + "delete": "Διαγραφή", + "edit": "Επεξεργασία", + "tags": "Ετικέτες", + "title": "Λεπτομέρειες Σημείωσης", + "updated": "Ενημερώθηκε" + }, + "empty": "Δεν βρέθηκαν σημειώσεις", + "emptyDescription": "Δεν έχουν δημιουργηθεί ακόμη σημειώσεις για το τμήμα σας.", + "search": "Αναζήτηση σημειώσεων...", + "title": "Σημειώσεις" + }, + "onboarding": { + "screen1": { + "title": "Resgrid Dispatch", + "description": "Δημιουργήστε, στείλτε και διαχειριστείτε κλήσεις έκτακτης ανάγκης με ένα ισχυρό κέντρο διοίκησης στα χέρια σας" + }, + "screen2": { + "title": "Επίγνωση Κατάστασης σε Πραγματικό Χρόνο", + "description": "Παρακολουθήστε όλες τις μονάδες, το προσωπικό και τους πόρους σε διαδραστικό χάρτη με ζωντανές ενημερώσεις κατάστασης και AVL" + }, + "screen3": { + "title": "Απρόσκοπτος Συντονισμός", + "description": "Επικοινωνήστε άμεσα με τις μονάδες πεδίου, ενημερώστε τις καταστάσεις κλήσεων και συντονίστε την ανταπόκριση από οπουδήποτε" + }, + "skip": "Παράλειψη", + "next": "Επόμενο", + "getStarted": "Ας Ξεκινήσουμε" + }, + "personnel": { + "title": "Προσωπικό", + "search": "Αναζήτηση προσωπικού...", + "loading": "Φόρτωση προσωπικού...", + "empty": "Δεν βρέθηκε προσωπικό", + "empty_description": "Δεν υπάρχει διαθέσιμο προσωπικό αυτή τη στιγμή στο τμήμα σας.", + "no_results": "Κανένα μέλος προσωπικού δεν ταιριάζει με την αναζήτησή σας", + "no_results_description": "Δοκιμάστε να προσαρμόσετε τους όρους αναζήτησης.", + "status": "Κατάσταση", + "staffing": "Στελέχωση", + "group": "Ομάδα", + "roles": "Ρόλοι", + "email": "Email", + "phone": "Τηλέφωνο", + "id_number": "Αριθμός Ταυτότητας", + "responding_to": "Ανταπόκριση Προς", + "status_updated": "Η Κατάσταση Ενημερώθηκε", + "staffing_updated": "Η Στελέχωση Ενημερώθηκε", + "details": "Λεπτομέρειες Προσωπικού", + "contact_info": "Στοιχεία Επικοινωνίας", + "status_info": "Κατάσταση και Στελέχωση", + "no_email": "Δεν υπάρχει διαθέσιμο email", + "no_phone": "Δεν υπάρχει διαθέσιμο τηλέφωνο", + "no_group": "Χωρίς ανάθεση", + "no_roles": "Δεν έχουν ανατεθεί ρόλοι", + "unknown_status": "Άγνωστη", + "on_duty": "Σε Υπηρεσία", + "off_duty": "Εκτός Υπηρεσίας", + "call_phone": "Κλήση", + "send_email": "Email", + "custom_fields": "Πρόσθετες Πληροφορίες" + }, + "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": "Σημείο ενδιαφέροντος χωρίς όνομα" + }, + "protocols": { + "details": { + "close": "Κλείσιμο", + "code": "Κωδικός", + "created": "Δημιουργήθηκε", + "title": "Λεπτομέρειες Πρωτοκόλλου", + "updated": "Ενημερώθηκε" + }, + "empty": "Δεν βρέθηκαν πρωτόκολλα", + "emptyDescription": "Δεν έχουν δημιουργηθεί ακόμη πρωτόκολλα για το τμήμα σας.", + "search": "Αναζήτηση πρωτοκόλλων...", + "title": "Πρωτόκολλα" + }, + "push_notifications": { + "close": "Κλείσιμο", + "message": "Μήνυμα", + "new_notification": "Νέα ειδοποίηση", + "title": "Τίτλος", + "types": { + "call": "Κλήση", + "chat": "Συνομιλία", + "group_chat": "Ομαδική Συνομιλία", + "message": "Μήνυμα", + "notification": "Ειδοποίηση" + }, + "unknown_type_warning": "Ελήφθη άγνωστος τύπος ειδοποίησης", + "view_call": "Προβολή κλήσης", + "view_message": "Προβολή μηνύματος" + }, + "roles": { + "modal": { + "title": "Αναθέσεις Ρόλων Μονάδας" + }, + "selectUser": "Επιλογή χρήστη", + "status": "{{active}} από {{total}} Ρόλους", + "tap_to_manage": "Πατήστε για διαχείριση ρόλων", + "unassigned": "Χωρίς ανάθεση" + }, + "scheduled_calls": { + "title": "Προγραμματισμένες Κλήσεις", + "loading": "Φόρτωση προγραμματισμένων κλήσεων...", + "no_scheduled_calls": "Δεν υπάρχουν προγραμματισμένες κλήσεις", + "no_scheduled_calls_description": "Δεν υπάρχουν εκκρεμείς προγραμματισμένες κλήσεις αυτή τη στιγμή.", + "search": "Αναζήτηση προγραμματισμένων κλήσεων...", + "scheduled_for": "Προγραμματισμένη για", + "table_number": "Κλήση #", + "table_name": "Όνομα", + "table_type": "Τύπος", + "table_priority": "Προτεραιότητα", + "table_address": "Διεύθυνση", + "table_scheduled": "Προγραμματισμένη Για" + }, + "settings": { + "about": "Σχετικά", + "account": "Λογαριασμός", + "active_unit": "Ενεργή Μονάδα", + "app_info": "Πληροφορίες Εφαρμογής", + "app_name": "Όνομα Εφαρμογής", + "arabic": "Αραβικά", + "audio_device_selection": { + "bluetooth_device": "Συσκευή Bluetooth", + "current_selection": "Τρέχουσα Επιλογή", + "microphone": "Μικρόφωνο", + "no_microphones_available": "Δεν υπάρχουν διαθέσιμα μικρόφωνα", + "no_speakers_available": "Δεν υπάρχουν διαθέσιμα ηχεία", + "none_selected": "Καμία επιλογή", + "speaker": "Ηχείο", + "speaker_device": "Συσκευή Ηχείου", + "title": "Επιλογή Συσκευής Ήχου", + "unavailable": "Μη διαθέσιμη", + "wired_device": "Ενσύρματη Συσκευή" + }, + "background_geolocation": "Γεωεντοπισμός στο Παρασκήνιο", + "background_geolocation_warning": "Αυτή η λειτουργία επιτρέπει στην εφαρμογή να παρακολουθεί την τοποθεσία σας στο παρασκήνιο. Βοηθά στον συντονισμό της ανταπόκρισης έκτακτης ανάγκης, αλλά μπορεί να επηρεάσει τη διάρκεια της μπαταρίας.", + "background_location": "Τοποθεσία στο Παρασκήνιο", + "contact_us": "Επικοινωνήστε Μαζί Μας", + "current_unit": "Τρέχουσα Μονάδα", + "english": "Αγγλικά", + "french": "Γαλλικά", + "german": "Γερμανικά", + "greek": "Ελληνικά", + "italian": "Ιταλικά", + "enter_password": "Εισαγάγετε τον κωδικό πρόσβασής σας", + "enter_server_url": "Εισαγάγετε τη διεύθυνση URL του Resgrid API (π.χ., https://api.resgrid.com)", + "enter_username": "Εισαγάγετε το όνομα χρήστη σας", + "environment": "Περιβάλλον", + "general": "Γενικά", + "generale": "Γενικά", + "github": "Github", + "help_center": "Κέντρο Βοήθειας", + "keep_alive": "Διατήρηση Ενεργού", + "keep_alive_warning": "Προειδοποίηση: Η ενεργοποίηση της διατήρησης ενεργού θα εμποδίσει τη συσκευή σας να μεταβεί σε αδράνεια και ενδέχεται να αυξήσει σημαντικά την κατανάλωση μπαταρίας.", + "keep_screen_on": "Διατήρηση Οθόνης Ενεργής", + "language": "Γλώσσα", + "links": "Σύνδεσμοι", + "login_info": "Στοιχεία Σύνδεσης", + "logout": "Αποσύνδεση", + "modern_notification_sounds": "Σύγχρονοι Ήχοι Ειδοποιήσεων", + "modern_notification_sounds_description": "Χρησιμοποιήστε τους νέους σύγχρονους ήχους για ειδοποιήσεις push. Απενεργοποιήστε το για να χρησιμοποιήσετε τους κλασικούς ήχους ειδοποιήσεων.", + "more": "Περισσότερα", + "no_units_available": "Δεν υπάρχουν διαθέσιμες μονάδες", + "none_selected": "Καμία Επιλογή", + "notifications": "Ειδοποιήσεις Push", + "notifications_description": "Ενεργοποιήστε τις ειδοποιήσεις για να λαμβάνετε ειδοποιήσεις και ενημερώσεις", + "notifications_enable": "Ενεργοποίηση Ειδοποιήσεων", + "password": "Κωδικός Πρόσβασης", + "preferences": "Προτιμήσεις", + "privacy": "Πολιτική Απορρήτου", + "privacy_policy": "Πολιτική Απορρήτου", + "rate": "Αξιολόγηση", + "select_unit": "Επιλογή Μονάδας", + "server": "Διακομιστής", + "server_url": "Διεύθυνση URL Διακομιστή", + "server_url_note": "Σημείωση: Αυτή είναι η διεύθυνση URL του Resgrid API. Χρησιμοποιείται για τη σύνδεση με τον διακομιστή Resgrid. Μην συμπεριλάβετε /api/v4 στη διεύθυνση URL ούτε τελική κάθετο.", + "select_server": "Επιλέξτε διακομιστή", + "custom": "Προσαρμοσμένος", + "loading_servers": "Φόρτωση διακομιστών...", + "set_active_unit": "Ορισμός Ενεργής Μονάδας", + "share": "Κοινή χρήση", + "polish": "Πολωνικά", + "spanish": "Ισπανικά", + "swedish": "Σουηδικά", + "ukrainian": "Ουκρανικά", + "status_page": "Κατάσταση Συστήματος", + "support": "Υποστήριξη", + "support_us": "Υποστηρίξτε Μας", + "terms": "Όροι Χρήσης", + "theme": { + "dark": "Σκούρο", + "light": "Ανοιχτό", + "system": "Συστήματος", + "title": "Θέμα" + }, + "title": "Ρυθμίσεις", + "unit_selected_successfully": "Η μονάδα {{unitName}} επιλέχθηκε με επιτυχία", + "unit_selection": "Επιλογή Μονάδας", + "unit_selection_failed": "Αποτυχία επιλογής μονάδας. Δοκιμάστε ξανά.", + "username": "Όνομα Χρήστη", + "version": "Έκδοση", + "website": "Ιστότοπος" + }, + "sso": { + "authenticating": "Ταυτοποίηση...", + "back_to_login": "Επιστροφή στη Σύνδεση", + "back_to_lookup": "Αλλαγή Χρήστη", + "continue_button": "Συνέχεια", + "department_id_label": "Αναγνωριστικό Τμήματος", + "department_id_placeholder": "Εισαγάγετε αναγνωριστικό τμήματος", + "error_generic": "Η σύνδεση απέτυχε. Δοκιμάστε ξανά.", + "error_oidc_cancelled": "Η σύνδεση ακυρώθηκε.", + "error_oidc_not_ready": "Ο πάροχος SSO φορτώνει, περιμένετε.", + "error_sso_not_enabled": "Η ενιαία σύνδεση δεν είναι ενεργοποιημένη για αυτόν τον χρήστη.", + "error_token_exchange": "Αποτυχία ολοκλήρωσης της σύνδεσης. Δοκιμάστε ξανά.", + "error_user_not_found": "Ο χρήστης δεν βρέθηκε. Ελέγξτε και δοκιμάστε ξανά.", + "looking_up": "Αναζήτηση...", + "optional": "προαιρετικό", + "page_subtitle": "Εισαγάγετε το όνομα χρήστη σας για να αναζητήσετε τις επιλογές σύνδεσης του οργανισμού σας.", + "page_title": "Ενιαία Σύνδεση", + "provider_oidc": "OpenID Connect (OIDC)", + "provider_saml": "SAML 2.0", + "sign_in_button": "Συνδεθείτε με SSO", + "sign_in_title": "Σύνδεση", + "sso_button": "Σύνδεση SSO" + }, + "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": "Επιλογή Κατάστασης", + "select_status_type": "Ποια κατάσταση θα θέλατε να ορίσετε;", + "selected_destination": "Επιλεγμένος Προορισμός", + "selected_status": "Επιλεγμένη Κατάσταση", + "set_status": "Ορισμός Κατάστασης", + "station_destination_enabled": "Μπορεί να ανταποκριθεί σε σταθμούς", + "stations_and_pois_destination_enabled": "Μπορεί να ανταποκριθεί σε σταθμούς ή σημεία ενδιαφέροντος", + "stations_tab": "Σταθμοί", + "status_saved_successfully": "Η κατάσταση αποθηκεύτηκε με επιτυχία!" + }, + "tabs": { + "calls": "Κλήσεις", + "calendar": "Ημερολόγιο", + "contacts": "Επαφές", + "home": "Αρχική", + "map": "Χάρτης", + "messages": "Μηνύματα", + "notes": "Σημειώσεις", + "protocols": "Πρωτόκολλα", + "settings": "Ρυθμίσεις", + "shifts": "Βάρδιες", + "personnel": "Προσωπικό" + }, + "units": { + "title": "Μονάδες", + "search": "Αναζήτηση μονάδων...", + "loading": "Φόρτωση μονάδων...", + "empty": "Δεν βρέθηκαν μονάδες", + "empty_description": "Δεν υπάρχουν διαθέσιμες μονάδες αυτή τη στιγμή στο τμήμα σας.", + "no_results": "Καμία μονάδα δεν ταιριάζει με την αναζήτησή σας", + "no_results_description": "Δοκιμάστε να προσαρμόσετε τους όρους αναζήτησης.", + "details": "Λεπτομέρειες Μονάδας", + "status": "Κατάσταση", + "group": "Ομάδα", + "type": "Τύπος", + "vin": "Αριθμός Πλαισίου", + "plate_number": "Αριθμός Πινακίδας", + "four_wheel_drive": "4WD", + "special_permit": "Ειδική Άδεια", + "yes": "Ναι", + "no": "Όχι", + "destination": "Προορισμός", + "status_updated": "Η Κατάσταση Ενημερώθηκε", + "roles": "Ανατεθειμένοι Ρόλοι", + "no_roles": "Δεν έχουν ανατεθεί ρόλοι", + "unit_info": "Πληροφορίες Μονάδας", + "status_info": "Πληροφορίες Κατάστασης", + "vehicle_info": "Πληροφορίες Οχήματος", + "custom_fields": "Πρόσθετες Πληροφορίες", + "unknown_status": "Άγνωστη", + "no_destination": "Κανένας" + }, + "videoFeeds": { + "title": "Ροές Βίντεο", + "noFeeds": "Δεν υπάρχουν ροές βίντεο για αυτή την κλήση", + "addFeed": "Προσθήκη Ροής Βίντεο", + "editFeed": "Επεξεργασία Ροής Βίντεο", + "deleteFeed": "Διαγραφή Ροής Βίντεο", + "deleteConfirm": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε αυτή τη ροή βίντεο;", + "watch": "Παρακολούθηση", + "goLive": "Έναρξη Ζωντανής Μετάδοσης", + "stopLive": "Διακοπή Ζωντανής Μετάδοσης", + "flipCamera": "Εναλλαγή Κάμερας", + "feedAdded": "Η ροή βίντεο προστέθηκε", + "feedUpdated": "Η ροή βίντεο ενημερώθηκε", + "feedDeleted": "Η ροή βίντεο αφαιρέθηκε", + "feedError": "Αποτυχία φόρτωσης ροής βίντεο", + "unsupportedFormat": "Αυτή η μορφή ροής δεν υποστηρίζεται σε κινητά", + "copyUrl": "Αντιγραφή Διεύθυνσης URL", + "form": { + "name": "Όνομα Ροής", + "namePlaceholder": "π.χ. Drone Οχήματος 1", + "url": "Διεύθυνση URL Ροής", + "urlPlaceholder": "π.χ. https://stream.example.com/live.m3u8", + "feedType": "Τύπος Κάμερας", + "feedFormat": "Μορφή Ροής", + "description": "Περιγραφή", + "descriptionPlaceholder": "Προαιρετική περιγραφή", + "status": "Κατάσταση", + "sortOrder": "Σειρά Ταξινόμησης", + "cameraLocation": "Τοποθεσία Κάμερας", + "useCurrentLocation": "Χρήση Τρέχουσας Τοποθεσίας" + }, + "type": { + "drone": "Drone", + "fixedCamera": "Σταθερή Κάμερα", + "bodyCam": "Κάμερα Σώματος", + "trafficCam": "Κάμερα Κυκλοφορίας", + "weatherCam": "Κάμερα Καιρού", + "satelliteFeed": "Δορυφορική Ροή", + "webCam": "Διαδικτυακή Κάμερα", + "other": "Άλλη" + }, + "format": { + "rtsp": "RTSP", + "hls": "HLS", + "mjpeg": "MJPEG", + "youtubeLive": "YouTube Live", + "webrtc": "WebRTC", + "dash": "DASH", + "embed": "Ενσωμάτωση", + "other": "Άλλη" + }, + "status": { + "active": "Ενεργή", + "inactive": "Ανενεργή", + "error": "Σφάλμα" + } + }, + "weatherAlerts": { + "title": "Καιρικές Ειδοποιήσεις", + "activeAlerts": "Ενεργές Ειδοποιήσεις", + "noActiveAlerts": "Καμία ενεργή καιρική ειδοποίηση", + "moreAlerts": "+{{count}} ακόμη", + "severity": { + "extreme": "Ακραία", + "severe": "Σοβαρή", + "moderate": "Μέτρια", + "minor": "Ήπια", + "unknown": "Άγνωστη" + }, + "category": { + "met": "Μετεωρολογική", + "fire": "Πυρκαγιά", + "health": "Υγεία", + "env": "Περιβαλλοντική", + "other": "Άλλη" + }, + "urgency": { + "immediate": "Άμεσο", + "expected": "Αναμενόμενο", + "future": "Μελλοντικό", + "past": "Παρελθόν", + "unknown": "Άγνωστο" + }, + "certainty": { + "observed": "Παρατηρημένη", + "likely": "Πιθανή", + "possible": "Ενδεχόμενη", + "unlikely": "Απίθανη", + "unknown": "Άγνωστη" + }, + "status": { + "active": "Ενεργή", + "updated": "Ενημερώθηκε", + "expired": "Έληξε", + "cancelled": "Ακυρώθηκε" + }, + "detail": { + "headline": "Επικεφαλίδα", + "description": "Περιγραφή", + "instruction": "Οδηγίες", + "area": "Επηρεαζόμενη Περιοχή", + "effective": "Ισχύει Από", + "onset": "Έναρξη", + "expires": "Λήγει", + "sent": "Στάλθηκε", + "sender": "Πηγή", + "urgency": "Επείγον", + "certainty": "Βεβαιότητα" + }, + "filter": { + "all": "Όλες", + "nearby": "Κοντινές" + }, + "sort": { + "severity": "Σοβαρότητα", + "expires": "Λήγει Σύντομα", + "newest": "Νεότερες" + }, + "banner": { + "viewAll": "Προβολή Όλων" + }, + "settings": { + "title": "Ρυθμίσεις Καιρικών Ειδοποιήσεων", + "general": "Γενικά", + "enabled": "Οι καιρικές ειδοποιήσεις είναι ενεργοποιημένες", + "minimum_severity": "Ελάχιστη σοβαρότητα", + "auto_message_severity": "Σοβαρότητα αυτόματου μηνύματος", + "call_integration": "Επισύναψη ειδοποιήσεων σε κλήσεις", + "excluded_events": "Εξαιρούμενα συμβάντα", + "excluded_events_placeholder": "Ονόματα συμβάντων προς παράβλεψη, χωρισμένα με κόμμα", + "save": "Αποθήκευση", + "saved": "Αποθηκεύτηκε", + "save_error": "Αποτυχία αποθήκευσης", + "name_required": "Το όνομα είναι υποχρεωτικό", + "zones": "Ζώνες Ειδοποιήσεων", + "add_zone": "Προσθήκη Ζώνης", + "edit_zone": "Επεξεργασία Ζώνης", + "no_zones": "Δεν έχουν διαμορφωθεί ζώνες.", + "zone_name": "Όνομα ζώνης", + "zone_code": "Κωδικός ζώνης", + "center_geo": "Κέντρο (πλάτος,μήκος)", + "radius_miles": "Ακτίνα (μίλια)", + "active": "Ενεργή", + "inactive": "Ανενεργή", + "primary": "Κύρια", + "delete_zone_confirm": "Διαγραφή αυτής της ζώνης;", + "sources": "Πηγές Ειδοποιήσεων", + "add_source": "Προσθήκη Πηγής", + "edit_source": "Επεξεργασία Πηγής", + "no_sources": "Δεν έχουν διαμορφωθεί πηγές.", + "source_name": "Όνομα πηγής", + "source_type": "Τύπος πηγής", + "area_filter": "Φίλτρο περιοχής", + "api_key": "Κλειδί API", + "api_key_set": "(ορισμένο - αφήστε το κενό για διατήρηση)", + "custom_endpoint": "Προσαρμοσμένο τελικό σημείο", + "poll_interval": "Διάστημα ανίχνευσης (λεπτά)", + "delete_source_confirm": "Διαγραφή αυτής της πηγής;", + "source_type_national_weather_service": "National Weather Service", + "source_type_environment_canada": "Environment Canada", + "source_type_meteoalarm": "MeteoAlarm" + }, + "stats_label": "Καιρικές Ειδοποιήσεις" + }, + "welcome": "Καλώς ήρθατε στον ιστότοπο της εφαρμογής obytes", + "run_cards": { + "title": "Σύσταση σχεδίου απόκρισης", + "checking": "Έλεγχος σχεδίων απόκρισης…", + "lookup_failed": "Δεν ήταν δυνατός ο έλεγχος. Μπορείτε να στείλετε χειροκίνητα.", + "summary": "Προτείνονται {{units}} μονάδες, {{personnel}} άτομα", + "units_section": "Προτεινόμενες μονάδες ({{count}})", + "personnel_section": "Προτεινόμενο προσωπικό ({{count}})", + "shortfalls_section": "Δεν καλύφθηκαν", + "shortfall_line": "{{name}}: {{filled}} από {{required}} — {{reason}}", + "move_ups_section": "Μετακινήσεις κάλυψης", + "move_up_line": "{{station}}: {{available}} από {{minimum}} μετά την αποστολή — πρόταση {{resource}}", + "move_up_no_donor": "δεν βρέθηκε πηγή", + "notes_section": "Πώς αποφασίστηκε", + "apply": "Εφαρμογή σύστασης", + "applied": "Η σύσταση εφαρμόστηκε", + "stale_location": "Παλιά θέση", + "auto_dispatch": "Αυτόματη αποστολή", + "auto_dispatch_explainer": "Αυτό το τμήμα στέλνει αυτόματα τα σχέδια που ταιριάζουν· οι πόροι ειδοποιούνται με τη δημιουργία της κλήσης.", + "alarm_level": "Συναγερμός {{level}}", + "alarm_level_label": "Επίπεδο συναγερμού", + "escalate": "Κλιμάκωση συναγερμού {{level}}", + "escalate_confirm": "Κλιμάκωση συναγερμού {{level}}; Θα σταλούν οι πόροι του επόμενου επιπέδου και θα ειδοποιηθούν μόνο οι νέοι.", + "escalate_confirm_action": "Κλιμάκωση συναγερμού", + "escalate_succeeded": "Συναγερμός {{level}} — προστέθηκαν {{units}} μονάδες, {{personnel}} άτομα", + "escalate_nothing_to_add": "Δεν υπάρχει κάτι να προστεθεί στο επόμενο επίπεδο", + "escalate_failed": "Η κλιμάκωση απέτυχε. Δοκιμάστε ξανά.", + "mode": { + "manual_only": "Χειροκίνητη επιλογή", + "station_based": "Βάσει σταθμού", + "closest_unit": "Πλησιέστερη μονάδα" + }, + "reason": { + "unknown": "Επιλέχθηκε", + "in_geofence": "Εντός περιοχής σταθμού", + "cascade_station": "Πλησιέστερος σταθμός", + "closest_by_distance": "Πλησιέστερο σε απόσταση", + "closest_by_eta": "Πλησιέστερο σε χρόνο", + "rest_period_overridden": "Παράκαμψη χρόνου ανάπαυσης" + }, + "shortfall": { + "unknown": "χωρίς αιτιολογία", + "no_candidates": "καμία διαθέσιμη", + "outside_radius": "εκτός ακτίνας αναζήτησης", + "locations_stale": "πολύ παλιές θέσεις", + "no_location_data": "χωρίς δεδομένα θέσης", + "not_staffed": "ανεπαρκές πλήρωμα", + "all_in_rest_period": "όλοι σε ανάπαυση", + "stations_exhausted": "δεν απομένουν σταθμοί" + } + } +} diff --git a/src/translations/en.json b/src/translations/en.json index 0d4f9195..e0aaffe9 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -295,6 +295,8 @@ "select_address_placeholder": "Select the address of the call", "select_description": "Select Description", "select_dispatch_recipients": "Select Dispatch Recipients", + "dispatch_recipients_partial_load": "Some recipients couldn't be loaded. The lists below may be incomplete.", + "dispatch_recipients_empty": "No personnel, groups, roles or units are visible to you. Check your department's permissions, or dispatch to Everyone.", "select_destination_poi": "Select destination POI", "select_location": "Select Location on Map", "select_name": "Select Name", @@ -369,7 +371,9 @@ "no_audio_description": "Audio attached to this call will appear here.", "error": "Failed to load audio", "audio_name": "Audio clip" - } + }, + "required_fields_missing": "Your department requires these fields before a call can be created: {{fields}}", + "field_policy_loading": "Still loading your department's call settings, please try again in a moment" }, "chat": { "title": "Chat", @@ -1215,6 +1219,7 @@ "english": "English", "french": "French", "german": "German", + "greek": "Greek", "italian": "Italian", "enter_password": "Enter your password", "enter_server_url": "Enter Resgrid API URL (e.g., https://api.resgrid.com)", @@ -1536,5 +1541,55 @@ }, "stats_label": "Weather Alerts" }, - "welcome": "Welcome to obytes app site" + "welcome": "Welcome to obytes app site", + "run_cards": { + "title": "Run card recommendation", + "checking": "Checking run cards…", + "lookup_failed": "Couldn't check run cards. You can still dispatch manually.", + "summary": "{{units}} unit(s), {{personnel}} responder(s) recommended", + "units_section": "Recommended units ({{count}})", + "personnel_section": "Recommended personnel ({{count}})", + "shortfalls_section": "Could not fill", + "shortfall_line": "{{name}}: {{filled}} of {{required}} — {{reason}}", + "move_ups_section": "Coverage move-ups", + "move_up_line": "{{station}}: {{available}} of {{minimum}} after dispatch — suggest {{resource}}", + "move_up_no_donor": "no donor found", + "notes_section": "How this was decided", + "apply": "Apply recommendation", + "applied": "Recommendation applied", + "stale_location": "Stale location", + "auto_dispatch": "Auto dispatch", + "auto_dispatch_explainer": "This department auto-dispatches matching run cards; these resources are alerted when the call is created.", + "alarm_level": "Alarm {{level}}", + "alarm_level_label": "Alarm level", + "escalate": "Strike alarm {{level}}", + "escalate_confirm": "Strike alarm {{level}}? This dispatches the next level's resources and alerts only the newly added ones.", + "escalate_confirm_action": "Strike alarm", + "escalate_succeeded": "Alarm {{level}} struck — {{units}} unit(s), {{personnel}} responder(s) added", + "escalate_nothing_to_add": "Nothing to add at the next alarm level", + "escalate_failed": "Couldn't escalate the call. Please try again.", + "mode": { + "manual_only": "Manual selection", + "station_based": "Station based", + "closest_unit": "Closest unit" + }, + "reason": { + "unknown": "Selected", + "in_geofence": "In station area", + "cascade_station": "Nearest station", + "closest_by_distance": "Closest by distance", + "closest_by_eta": "Closest by ETA", + "rest_period_overridden": "Rest period overridden" + }, + "shortfall": { + "unknown": "no reason given", + "no_candidates": "nothing available", + "outside_radius": "outside search radius", + "locations_stale": "locations too old", + "no_location_data": "no location data", + "not_staffed": "not enough crew", + "all_in_rest_period": "all in rest period", + "stations_exhausted": "no stations left to search" + } + } } diff --git a/src/translations/es.json b/src/translations/es.json index f5781fce..e42dd85a 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -295,6 +295,8 @@ "select_address_placeholder": "Selecciona la dirección de la llamada", "select_description": "Seleccionar descripción", "select_dispatch_recipients": "Seleccionar Destinatarios de Despacho", + "dispatch_recipients_partial_load": "No se pudieron cargar algunos destinatarios. Las listas siguientes pueden estar incompletas.", + "dispatch_recipients_empty": "No hay personal, grupos, roles ni unidades visibles para ti. Revisa los permisos de tu departamento o despacha a Todos.", "select_destination_poi": "Selecciona el PDI de destino", "select_location": "Seleccionar ubicación en el mapa", "select_name": "Seleccionar nombre", @@ -369,7 +371,9 @@ "no_audio_description": "El audio adjunto a esta llamada aparecerá aquí.", "error": "No se pudo cargar el audio", "audio_name": "Clip de audio" - } + }, + "required_fields_missing": "Tu departamento exige estos campos antes de poder crear una llamada: {{fields}}", + "field_policy_loading": "Aún se están cargando los ajustes de llamadas de tu departamento, inténtalo de nuevo en un momento" }, "chat": { "title": "Chat", @@ -1215,6 +1219,7 @@ "english": "Inglés", "french": "Francés", "german": "Alemán", + "greek": "Griego", "italian": "Italiano", "enter_password": "Introduce tu contraseña", "enter_server_url": "Introduce la URL de la API de Resgrid (ej: https://api.resgrid.com)", @@ -1536,5 +1541,55 @@ }, "stats_label": "Alertas Meteorológicas" }, - "welcome": "Bienvenido al sitio de la aplicación obytes" + "welcome": "Bienvenido al sitio de la aplicación obytes", + "run_cards": { + "title": "Recomendación del plan de respuesta", + "checking": "Comprobando planes de respuesta…", + "lookup_failed": "No se pudieron comprobar los planes de respuesta. Aún puedes despachar manualmente.", + "summary": "{{units}} unidad(es), {{personnel}} efectivo(s) recomendados", + "units_section": "Unidades recomendadas ({{count}})", + "personnel_section": "Personal recomendado ({{count}})", + "shortfalls_section": "No se pudo cubrir", + "shortfall_line": "{{name}}: {{filled}} de {{required}} — {{reason}}", + "move_ups_section": "Reubicaciones de cobertura", + "move_up_line": "{{station}}: {{available}} de {{minimum}} tras el despacho — se sugiere {{resource}}", + "move_up_no_donor": "no se encontró origen", + "notes_section": "Cómo se decidió", + "apply": "Aplicar recomendación", + "applied": "Recomendación aplicada", + "stale_location": "Ubicación desactualizada", + "auto_dispatch": "Despacho automático", + "auto_dispatch_explainer": "Este departamento despacha automáticamente los planes de respuesta coincidentes; estos recursos se alertan al crear la llamada.", + "alarm_level": "Alarma {{level}}", + "alarm_level_label": "Nivel de alarma", + "escalate": "Activar alarma {{level}}", + "escalate_confirm": "¿Activar la alarma {{level}}? Se despacharán los recursos del siguiente nivel y solo se alertará a los nuevos.", + "escalate_confirm_action": "Activar alarma", + "escalate_succeeded": "Alarma {{level}} activada — {{units}} unidad(es), {{personnel}} efectivo(s) añadidos", + "escalate_nothing_to_add": "No hay nada que añadir en el siguiente nivel de alarma", + "escalate_failed": "No se pudo escalar la llamada. Inténtalo de nuevo.", + "mode": { + "manual_only": "Selección manual", + "station_based": "Por parque", + "closest_unit": "Unidad más cercana" + }, + "reason": { + "unknown": "Seleccionado", + "in_geofence": "En la zona del parque", + "cascade_station": "Parque más cercano", + "closest_by_distance": "Más cercano por distancia", + "closest_by_eta": "Más cercano por tiempo", + "rest_period_overridden": "Periodo de descanso omitido" + }, + "shortfall": { + "unknown": "sin motivo indicado", + "no_candidates": "nada disponible", + "outside_radius": "fuera del radio de búsqueda", + "locations_stale": "ubicaciones demasiado antiguas", + "no_location_data": "sin datos de ubicación", + "not_staffed": "dotación insuficiente", + "all_in_rest_period": "todos en periodo de descanso", + "stations_exhausted": "no quedan parques por buscar" + } + } } diff --git a/src/translations/fr.json b/src/translations/fr.json index 3ece4bf6..d936502e 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -296,6 +296,8 @@ "select_address_placeholder": "Sélectionner l'adresse de l'appel", "select_description": "Sélectionner la description", "select_dispatch_recipients": "Sélectionner les destinataires du dispatch", + "dispatch_recipients_partial_load": "Certains destinataires n'ont pas pu être chargés. Les listes ci-dessous peuvent être incomplètes.", + "dispatch_recipients_empty": "Aucun personnel, groupe, rôle ou unité ne vous est visible. Vérifiez les autorisations de votre service ou envoyez à Tout le monde.", "select_location": "Sélectionner l'emplacement sur la carte", "select_name": "Sélectionner le nom", "select_nature": "Sélectionner la nature", @@ -369,7 +371,9 @@ "no_audio_description": "Les fichiers audio joints à cette intervention apparaîtront ici.", "error": "Impossible de charger l’audio", "audio_name": "Extrait audio" - } + }, + "required_fields_missing": "Votre service exige ces champs avant qu'un appel puisse être créé : {{fields}}", + "field_policy_loading": "Les paramètres d'appel de votre service sont encore en cours de chargement, veuillez réessayer dans un instant" }, "chat": { "title": "Chat", @@ -1215,6 +1219,7 @@ "english": "Anglais", "french": "Français", "german": "Allemand", + "greek": "Grec", "italian": "Italien", "enter_password": "Saisissez votre mot de passe", "enter_server_url": "Saisissez l'URL de l'API Resgrid (ex. : https://api.resgrid.com)", @@ -1536,5 +1541,55 @@ }, "stats_label": "Alertes météo" }, - "welcome": "Bienvenue sur le site de l'application obytes" + "welcome": "Bienvenue sur le site de l'application obytes", + "run_cards": { + "title": "Recommandation du plan de réponse", + "checking": "Vérification des plans de réponse…", + "lookup_failed": "Impossible de vérifier les plans de réponse. L'envoi manuel reste possible.", + "summary": "{{units}} unité(s), {{personnel}} intervenant(s) recommandé(s)", + "units_section": "Unités recommandées ({{count}})", + "personnel_section": "Personnel recommandé ({{count}})", + "shortfalls_section": "Non pourvu", + "shortfall_line": "{{name}} : {{filled}} sur {{required}} — {{reason}}", + "move_ups_section": "Redéploiements de couverture", + "move_up_line": "{{station}} : {{available}} sur {{minimum}} après envoi — suggestion {{resource}}", + "move_up_no_donor": "aucune source trouvée", + "notes_section": "Comment cela a été décidé", + "apply": "Appliquer la recommandation", + "applied": "Recommandation appliquée", + "stale_location": "Position obsolète", + "auto_dispatch": "Envoi automatique", + "auto_dispatch_explainer": "Ce service envoie automatiquement les plans de réponse correspondants ; ces ressources sont alertées à la création de l'intervention.", + "alarm_level": "Alerte {{level}}", + "alarm_level_label": "Niveau d'alerte", + "escalate": "Déclencher l'alerte {{level}}", + "escalate_confirm": "Déclencher l'alerte {{level}} ? Cela envoie les ressources du niveau suivant et n'alerte que celles qui sont ajoutées.", + "escalate_confirm_action": "Déclencher l'alerte", + "escalate_succeeded": "Alerte {{level}} déclenchée — {{units}} unité(s), {{personnel}} intervenant(s) ajouté(s)", + "escalate_nothing_to_add": "Rien à ajouter au niveau d'alerte suivant", + "escalate_failed": "Impossible d'escalader l'intervention. Veuillez réessayer.", + "mode": { + "manual_only": "Sélection manuelle", + "station_based": "Par caserne", + "closest_unit": "Unité la plus proche" + }, + "reason": { + "unknown": "Sélectionné", + "in_geofence": "Dans le secteur de la caserne", + "cascade_station": "Caserne la plus proche", + "closest_by_distance": "Plus proche en distance", + "closest_by_eta": "Plus proche en temps de trajet", + "rest_period_overridden": "Période de repos outrepassée" + }, + "shortfall": { + "unknown": "aucune raison indiquée", + "no_candidates": "rien de disponible", + "outside_radius": "hors du rayon de recherche", + "locations_stale": "positions trop anciennes", + "no_location_data": "aucune donnée de position", + "not_staffed": "effectif insuffisant", + "all_in_rest_period": "tous en période de repos", + "stations_exhausted": "plus aucune caserne à explorer" + } + } } diff --git a/src/translations/it.json b/src/translations/it.json index 91ac428f..ce2860a8 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -296,6 +296,8 @@ "select_address_placeholder": "Seleziona l'indirizzo dell'intervento", "select_description": "Seleziona descrizione", "select_dispatch_recipients": "Seleziona destinatari invio", + "dispatch_recipients_partial_load": "Non è stato possibile caricare alcuni destinatari. Gli elenchi seguenti potrebbero essere incompleti.", + "dispatch_recipients_empty": "Nessun personale, gruppo, ruolo o unità è visibile. Controlla i permessi del tuo dipartimento oppure invia a Tutti.", "select_location": "Seleziona posizione sulla mappa", "select_name": "Seleziona nome", "select_nature": "Seleziona natura", @@ -369,7 +371,9 @@ "no_audio_description": "L’audio allegato a questa chiamata verrà visualizzato qui.", "error": "Impossibile caricare l’audio", "audio_name": "Clip audio" - } + }, + "required_fields_missing": "Il tuo dipartimento richiede questi campi prima di poter creare una chiamata: {{fields}}", + "field_policy_loading": "Le impostazioni delle chiamate del tuo dipartimento sono ancora in caricamento, riprova tra un momento" }, "chat": { "title": "Chat", @@ -1215,6 +1219,7 @@ "english": "Inglese", "french": "Francese", "german": "Tedesco", + "greek": "Greco", "italian": "Italiano", "enter_password": "Inserisci la tua password", "enter_server_url": "Inserisci l'URL dell'API Resgrid (es. https://api.resgrid.com)", @@ -1536,5 +1541,55 @@ }, "stats_label": "Allerte meteo" }, - "welcome": "Benvenuto nel sito dell'app obytes" + "welcome": "Benvenuto nel sito dell'app obytes", + "run_cards": { + "title": "Raccomandazione del piano di risposta", + "checking": "Verifica dei piani di risposta…", + "lookup_failed": "Impossibile verificare i piani di risposta. Puoi comunque inviare manualmente.", + "summary": "{{units}} unità, {{personnel}} operatori consigliati", + "units_section": "Unità consigliate ({{count}})", + "personnel_section": "Personale consigliato ({{count}})", + "shortfalls_section": "Non copribile", + "shortfall_line": "{{name}}: {{filled}} su {{required}} — {{reason}}", + "move_ups_section": "Ricollocazioni di copertura", + "move_up_line": "{{station}}: {{available}} su {{minimum}} dopo l'invio — si suggerisce {{resource}}", + "move_up_no_donor": "nessuna origine trovata", + "notes_section": "Come è stato deciso", + "apply": "Applica la raccomandazione", + "applied": "Raccomandazione applicata", + "stale_location": "Posizione non aggiornata", + "auto_dispatch": "Invio automatico", + "auto_dispatch_explainer": "Questo dipartimento invia automaticamente i piani di risposta corrispondenti; queste risorse vengono allertate alla creazione della chiamata.", + "alarm_level": "Allarme {{level}}", + "alarm_level_label": "Livello di allarme", + "escalate": "Attiva allarme {{level}}", + "escalate_confirm": "Attivare l'allarme {{level}}? Verranno inviate le risorse del livello successivo e allertate solo quelle appena aggiunte.", + "escalate_confirm_action": "Attiva allarme", + "escalate_succeeded": "Allarme {{level}} attivato — {{units}} unità, {{personnel}} operatori aggiunti", + "escalate_nothing_to_add": "Nulla da aggiungere al livello di allarme successivo", + "escalate_failed": "Impossibile far salire di livello la chiamata. Riprova.", + "mode": { + "manual_only": "Selezione manuale", + "station_based": "Per sede", + "closest_unit": "Unità più vicina" + }, + "reason": { + "unknown": "Selezionato", + "in_geofence": "Nell'area della sede", + "cascade_station": "Sede più vicina", + "closest_by_distance": "Più vicino per distanza", + "closest_by_eta": "Più vicino per tempo di percorrenza", + "rest_period_overridden": "Periodo di riposo ignorato" + }, + "shortfall": { + "unknown": "nessun motivo indicato", + "no_candidates": "nulla disponibile", + "outside_radius": "fuori dal raggio di ricerca", + "locations_stale": "posizioni troppo vecchie", + "no_location_data": "nessun dato di posizione", + "not_staffed": "equipaggio insufficiente", + "all_in_rest_period": "tutti in periodo di riposo", + "stations_exhausted": "nessuna sede rimasta da cercare" + } + } } diff --git a/src/translations/pl.json b/src/translations/pl.json index e2480e62..f707d94a 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -296,6 +296,8 @@ "select_address_placeholder": "Wybierz adres zgłoszenia", "select_description": "Wybierz opis", "select_dispatch_recipients": "Wybierz odbiorców dyspozycji", + "dispatch_recipients_partial_load": "Nie udało się wczytać niektórych odbiorców. Poniższe listy mogą być niekompletne.", + "dispatch_recipients_empty": "Nie widzisz żadnego personelu, grup, ról ani jednostek. Sprawdź uprawnienia swojego departamentu lub wyślij do Wszystkich.", "select_location": "Wybierz lokalizację na mapie", "select_name": "Wybierz nazwę", "select_nature": "Wybierz charakter", @@ -369,7 +371,9 @@ "no_audio_description": "Nagrania dołączone do tego zgłoszenia pojawią się tutaj.", "error": "Nie udało się załadować dźwięku", "audio_name": "Klip dźwiękowy" - } + }, + "required_fields_missing": "Twój departament wymaga tych pól przed utworzeniem zgłoszenia: {{fields}}", + "field_policy_loading": "Ustawienia zgłoszeń Twojego departamentu są jeszcze wczytywane, spróbuj ponownie za chwilę" }, "chat": { "title": "Czat", @@ -1215,6 +1219,7 @@ "english": "Angielski", "french": "Francuski", "german": "Niemiecki", + "greek": "Grecki", "italian": "Włoski", "enter_password": "Wprowadź swoje hasło", "enter_server_url": "Wprowadź adres URL API Resgrid (np. https://api.resgrid.com)", @@ -1536,5 +1541,55 @@ }, "stats_label": "Alerty pogodowe" }, - "welcome": "Witaj w aplikacji obytes" + "welcome": "Witaj w aplikacji obytes", + "run_cards": { + "title": "Rekomendacja planu zadysponowania", + "checking": "Sprawdzanie planów zadysponowania…", + "lookup_failed": "Nie udało się sprawdzić planów zadysponowania. Nadal możesz zadysponować ręcznie.", + "summary": "Zalecane: {{units}} jedn., {{personnel}} ratowników", + "units_section": "Zalecane jednostki ({{count}})", + "personnel_section": "Zalecany personel ({{count}})", + "shortfalls_section": "Nie udało się obsadzić", + "shortfall_line": "{{name}}: {{filled}} z {{required}} — {{reason}}", + "move_ups_section": "Przesunięcia dla pokrycia", + "move_up_line": "{{station}}: {{available}} z {{minimum}} po zadysponowaniu — sugestia {{resource}}", + "move_up_no_donor": "nie znaleziono źródła", + "notes_section": "Jak to ustalono", + "apply": "Zastosuj rekomendację", + "applied": "Rekomendacja zastosowana", + "stale_location": "Nieaktualna lokalizacja", + "auto_dispatch": "Automatyczne zadysponowanie", + "auto_dispatch_explainer": "Ten departament automatycznie wysyła pasujące plany; te zasoby są alarmowane przy tworzeniu zdarzenia.", + "alarm_level": "Alarm {{level}}", + "alarm_level_label": "Poziom alarmu", + "escalate": "Ogłoś alarm {{level}}", + "escalate_confirm": "Ogłosić alarm {{level}}? Zadysponuje to zasoby kolejnego poziomu i zaalarmuje tylko nowo dodane.", + "escalate_confirm_action": "Ogłoś alarm", + "escalate_succeeded": "Ogłoszono alarm {{level}} — dodano {{units}} jedn., {{personnel}} ratowników", + "escalate_nothing_to_add": "Brak zasobów do dodania na kolejnym poziomie alarmu", + "escalate_failed": "Nie udało się podnieść poziomu alarmu. Spróbuj ponownie.", + "mode": { + "manual_only": "Wybór ręczny", + "station_based": "Według jednostki", + "closest_unit": "Najbliższa jednostka" + }, + "reason": { + "unknown": "Wybrano", + "in_geofence": "W obszarze jednostki", + "cascade_station": "Najbliższa jednostka", + "closest_by_distance": "Najbliżej wg odległości", + "closest_by_eta": "Najbliżej wg czasu dojazdu", + "rest_period_overridden": "Pominięto okres odpoczynku" + }, + "shortfall": { + "unknown": "nie podano powodu", + "no_candidates": "brak dostępnych", + "outside_radius": "poza promieniem wyszukiwania", + "locations_stale": "lokalizacje zbyt stare", + "no_location_data": "brak danych lokalizacji", + "not_staffed": "za mała obsada", + "all_in_rest_period": "wszyscy w okresie odpoczynku", + "stations_exhausted": "brak kolejnych jednostek do sprawdzenia" + } + } } diff --git a/src/translations/sv.json b/src/translations/sv.json index cdd191c4..e92924b4 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -296,6 +296,8 @@ "select_address_placeholder": "Välj adressen för ärendet", "select_description": "Välj beskrivning", "select_dispatch_recipients": "Välj larmmottagare", + "dispatch_recipients_partial_load": "Vissa mottagare kunde inte läsas in. Listorna nedan kan vara ofullständiga.", + "dispatch_recipients_empty": "Ingen personal, inga grupper, roller eller enheter är synliga för dig. Kontrollera avdelningens behörigheter eller larma Alla.", "select_location": "Välj plats på kartan", "select_name": "Välj namn", "select_nature": "Välj händelsetyp", @@ -369,7 +371,9 @@ "no_audio_description": "Ljud som bifogats till det här larmet visas här.", "error": "Det gick inte att läsa in ljud", "audio_name": "Ljudklipp" - } + }, + "required_fields_missing": "Din avdelning kräver dessa fält innan ett ärende kan skapas: {{fields}}", + "field_policy_loading": "Din avdelnings ärendeinställningar laddas fortfarande, försök igen om ett ögonblick" }, "chat": { "title": "Chatt", @@ -1215,6 +1219,7 @@ "english": "Engelska", "french": "Franska", "german": "Tyska", + "greek": "Grekiska", "italian": "Italienska", "enter_password": "Ange ditt lösenord", "enter_server_url": "Ange Resgrid API-URL (t.ex. https://api.resgrid.com)", @@ -1536,5 +1541,55 @@ }, "stats_label": "Vädervarningar" }, - "welcome": "Välkommen till obytes app-webbplats" + "welcome": "Välkommen till obytes app-webbplats", + "run_cards": { + "title": "Rekommendation från insatsplan", + "checking": "Kontrollerar insatsplaner…", + "lookup_failed": "Kunde inte kontrollera insatsplanerna. Du kan fortfarande larma manuellt.", + "summary": "{{units}} enhet(er), {{personnel}} personer rekommenderas", + "units_section": "Rekommenderade enheter ({{count}})", + "personnel_section": "Rekommenderad personal ({{count}})", + "shortfalls_section": "Kunde inte fyllas", + "shortfall_line": "{{name}}: {{filled}} av {{required}} — {{reason}}", + "move_ups_section": "Omflättningar för täckning", + "move_up_line": "{{station}}: {{available}} av {{minimum}} efter larm — förslag {{resource}}", + "move_up_no_donor": "ingen källa hittad", + "notes_section": "Så här beslutades det", + "apply": "Använd rekommendationen", + "applied": "Rekommendationen använd", + "stale_location": "Gammal position", + "auto_dispatch": "Automatiskt larm", + "auto_dispatch_explainer": "Den här avdelningen larmar matchande insatsplaner automatiskt; dessa resurser larmas när ärendet skapas.", + "alarm_level": "Larm {{level}}", + "alarm_level_label": "Larmnivå", + "escalate": "Utlös larm {{level}}", + "escalate_confirm": "Utlösa larm {{level}}? Det larmar nästa nivås resurser och meddelar bara de nytillkomna.", + "escalate_confirm_action": "Utlös larm", + "escalate_succeeded": "Larm {{level}} utlöst — {{units}} enhet(er), {{personnel}} personer tillagda", + "escalate_nothing_to_add": "Inget att lägga till på nästa larmnivå", + "escalate_failed": "Kunde inte höja ärendets larmnivå. Försök igen.", + "mode": { + "manual_only": "Manuellt val", + "station_based": "Efter station", + "closest_unit": "Närmaste enhet" + }, + "reason": { + "unknown": "Vald", + "in_geofence": "Inom stationens område", + "cascade_station": "Närmaste station", + "closest_by_distance": "Närmast i avstånd", + "closest_by_eta": "Närmast i restid", + "rest_period_overridden": "Vilotid åsidosatt" + }, + "shortfall": { + "unknown": "ingen orsak angiven", + "no_candidates": "inget tillgängligt", + "outside_radius": "utanför sökradien", + "locations_stale": "positioner för gamla", + "no_location_data": "inga positionsdata", + "not_staffed": "för lite bemanning", + "all_in_rest_period": "alla i vilotid", + "stations_exhausted": "inga fler stationer att söka" + } + } } diff --git a/src/translations/uk.json b/src/translations/uk.json index 7e283130..d85c8be3 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -296,6 +296,8 @@ "select_address_placeholder": "Виберіть адресу виклику", "select_description": "Вибрати опис", "select_dispatch_recipients": "Вибрати отримувачів диспетчеризації", + "dispatch_recipients_partial_load": "Деяких отримувачів не вдалося завантажити. Списки нижче можуть бути неповними.", + "dispatch_recipients_empty": "Вам не видно жодного персоналу, груп, ролей чи підрозділів. Перевірте дозволи вашого відділу або надішліть Усім.", "select_location": "Вибрати місце на карті", "select_name": "Вибрати назву", "select_nature": "Вибрати характер", @@ -369,7 +371,9 @@ "no_audio_description": "Аудіо, додане до цього виклику, з’явиться тут.", "error": "Не вдалося завантажити аудіо", "audio_name": "Аудіокліп" - } + }, + "required_fields_missing": "Ваш відділ вимагає ці поля перед створенням виклику: {{fields}}", + "field_policy_loading": "Налаштування викликів вашого відділу ще завантажуються, спробуйте ще раз за мить" }, "chat": { "title": "Чат", @@ -1215,6 +1219,7 @@ "english": "Англійська", "french": "Французька", "german": "Німецька", + "greek": "Грецька", "italian": "Італійська", "enter_password": "Введіть ваш пароль", "enter_server_url": "Введіть URL API Resgrid (напр., https://api.resgrid.com)", @@ -1536,5 +1541,55 @@ }, "stats_label": "Погодні попередження" }, - "welcome": "Ласкаво просимо до додатку obytes" + "welcome": "Ласкаво просимо до додатку obytes", + "run_cards": { + "title": "Рекомендація плану реагування", + "checking": "Перевірка планів реагування…", + "lookup_failed": "Не вдалося перевірити плани реагування. Ви все ще можете вислати вручну.", + "summary": "Рекомендовано {{units}} підрозділів, {{personnel}} осіб", + "units_section": "Рекомендовані підрозділи ({{count}})", + "personnel_section": "Рекомендований персонал ({{count}})", + "shortfalls_section": "Не вдалося закрити", + "shortfall_line": "{{name}}: {{filled}} з {{required}} — {{reason}}", + "move_ups_section": "Переміщення для покриття", + "move_up_line": "{{station}}: {{available}} з {{minimum}} після висилання — пропозиція {{resource}}", + "move_up_no_donor": "джерела не знайдено", + "notes_section": "Як це було вирішено", + "apply": "Застосувати рекомендацію", + "applied": "Рекомендацію застосовано", + "stale_location": "Застаріле місцезнаходження", + "auto_dispatch": "Автоматичне висилання", + "auto_dispatch_explainer": "Цей відділ автоматично висилає за відповідними планами; ці ресурси сповіщаються під час створення виклику.", + "alarm_level": "Тривога {{level}}", + "alarm_level_label": "Рівень тривоги", + "escalate": "Оголосити тривогу {{level}}", + "escalate_confirm": "Оголосити тривогу {{level}}? Це вишле ресурси наступного рівня та сповістить лише новододаних.", + "escalate_confirm_action": "Оголосити тривогу", + "escalate_succeeded": "Тривога {{level}} — додано {{units}} підрозділів, {{personnel}} осіб", + "escalate_nothing_to_add": "На наступному рівні нічого додавати", + "escalate_failed": "Не вдалося підвищити рівень тривоги. Спробуйте ще раз.", + "mode": { + "manual_only": "Ручний вибір", + "station_based": "За частиною", + "closest_unit": "Найближчий підрозділ" + }, + "reason": { + "unknown": "Обрано", + "in_geofence": "У зоні частини", + "cascade_station": "Найближча частина", + "closest_by_distance": "Найближче за відстанню", + "closest_by_eta": "Найближче за часом", + "rest_period_overridden": "Період відпочинку проігноровано" + }, + "shortfall": { + "unknown": "причину не вказано", + "no_candidates": "немає доступних", + "outside_radius": "поза радіусом пошуку", + "locations_stale": "місцезнаходження занадто старі", + "no_location_data": "немає даних про місцезнаходження", + "not_staffed": "недостатньо екіпажу", + "all_in_rest_period": "усі на відпочинку", + "stations_exhausted": "більше немає частин для пошуку" + } + } }