From e5ecc09e3626bf1c3a5cf61fe3dc72c88caa802d Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 14 Aug 2026 14:06:46 -0700 Subject: [PATCH 1/3] RG-T131 Greek Support, Cache Bug fixes, Centerpoint Fix --- .github/workflows/react-native-cicd.yml | 64 +- src/api/calls/callPriorities.ts | 2 +- src/api/calls/callTypes.ts | 2 +- src/api/calls/newCallFieldPolicy.ts | 22 + src/api/common/cached-client.ts | 72 +- src/api/config/index.ts | 4 +- src/api/contacts/contacts.ts | 4 +- src/api/groups/groups.ts | 2 +- src/api/messaging/messages.ts | 2 +- src/api/notes/notes.ts | 6 +- src/api/personnel/personnel.ts | 2 +- src/api/protocols/protocols.ts | 2 +- src/api/units/unitRoles.ts | 2 +- src/api/units/units.ts | 10 +- src/api/voice/index.ts | 4 +- src/app/(app)/index.tsx | 3 +- .../call/new/__tests__/address-search.test.ts | 6 + .../new/__tests__/coordinates-search.test.tsx | 6 + .../new/__tests__/plus-code-search.test.ts | 6 + .../call/new/__tests__/what3words.test.tsx | 3 + src/app/call/new/index.tsx | 110 +- src/app/login/login-form.tsx | 1 + .../maps/full-screen-location-picker.tsx | 10 +- src/components/maps/location-picker.tsx | 10 +- src/components/maps/map-view.web.tsx | 3 +- src/components/maps/pin-marker.tsx | 3 +- ...nit-selection-bottom-sheet-simple.test.tsx | 21 + .../unit-selection-bottom-sheet.test.tsx | 46 +- src/components/settings/language-item.tsx | 1 + .../settings/unit-selection-bottom-sheet.tsx | 38 +- src/hooks/use-new-call-field-policy.ts | 137 ++ src/lib/cache/__tests__/cache-manager.test.ts | 8 +- src/lib/cache/cache-manager.ts | 8 +- src/lib/cache/cache-scope.ts | 82 ++ src/lib/i18n/resources.ts | 4 + src/lib/map-center.ts | 60 + .../v4/calls/newCallFieldPolicyResultData.ts | 38 + src/models/v4/configs/getConfigResultData.ts | 6 + src/stores/auth/store.tsx | 33 + src/stores/units/store.ts | 29 +- src/translations/ar.json | 6 +- src/translations/de.json | 6 +- src/translations/el.json | 1195 +++++++++++++++++ src/translations/en.json | 6 +- src/translations/es.json | 6 +- src/translations/fr.json | 6 +- src/translations/it.json | 6 +- src/translations/pl.json | 6 +- src/translations/sv.json | 6 +- src/translations/uk.json | 6 +- 50 files changed, 1981 insertions(+), 140 deletions(-) create mode 100644 src/api/calls/newCallFieldPolicy.ts create mode 100644 src/hooks/use-new-call-field-policy.ts create mode 100644 src/lib/cache/cache-scope.ts create mode 100644 src/lib/map-center.ts create mode 100644 src/models/v4/calls/newCallFieldPolicyResultData.ts create mode 100644 src/translations/el.json diff --git a/.github/workflows/react-native-cicd.yml b/.github/workflows/react-native-cicd.yml index 2076a344..ec5a104d 100644 --- a/.github/workflows/react-native-cicd.yml +++ b/.github/workflows/react-native-cicd.yml @@ -101,15 +101,17 @@ jobs: node-version: '24' cache: 'yarn' - - name: 📦 Setup yarn cache + # 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 + # 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: | @@ -407,15 +411,17 @@ jobs: node-version: '24' cache: 'yarn' - - name: 📦 Setup yarn cache + # 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 @@ -531,15 +537,17 @@ jobs: node-version: '24' cache: 'yarn' - - name: 📦 Setup yarn cache + # 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 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 3e24a458..71216d79 100644 --- a/src/api/common/cached-client.ts +++ b/src/api/common/cached-client.ts @@ -9,29 +9,79 @@ 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 return { - get: async (params?: Record): Promise> => { + get: async (params?: Record, options?: GetOptions): Promise> => { if (!cacheConfig.enabled) { return api.get(params); } - const cached = cacheManager.get(endpoint, params); - if (cached) { - return Promise.resolve({ - data: cached, - status: 200, - statusText: 'OK (cached)', - headers: {}, - config: {}, - } as AxiosResponse); + if (!options?.forceRefresh) { + const cached = cacheManager.get(endpoint, params); + if (cached) { + return Promise.resolve({ + data: cached, + status: 200, + statusText: 'OK (cached)', + headers: {}, + config: {}, + } as AxiosResponse); + } } const response = await api.get(params); - cacheManager.set(endpoint, response.data, params, cacheConfig.ttl || defaultTTL); + + 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); + } + return response; }, post: api.post, diff --git a/src/api/config/index.ts b/src/api/config/index.ts index 332ea7a2..94b89a60 100644 --- a/src/api/config/index.ts +++ b/src/api/config/index.ts @@ -4,12 +4,12 @@ import { type GetSystemConfigResult } from '@/models/v4/configs/getSystemConfigR import { createCachedApiEndpoint } from '../common/cached-client'; const getConfigApi = createCachedApiEndpoint('/Config/GetConfig', { - ttl: 60 * 1000 * 1440, // Cache for 1 days + ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale enabled: false, }); const getSystemConfigApi = createCachedApiEndpoint('/Config/GetSystemConfig', { - ttl: 60 * 1000 * 1440, // Cache for 1 days + ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale enabled: false, }); 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/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 7fe92695..14175fb4 100644 --- a/src/api/notes/notes.ts +++ b/src/api/notes/notes.ts @@ -9,17 +9,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 8f784815..162642cb 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/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)/index.tsx b/src/app/(app)/index.tsx index 851d4c72..9745c5de 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -28,6 +28,7 @@ import { useMapsStore } from '@/stores/maps/store'; import { useRoutesStore } from '@/stores/routes/store'; import { useToastStore } from '@/stores/toast/store'; import { useWeatherAlertsStore } from '@/stores/weather-alerts/store'; +import { getDepartmentMapCenter } from '@/lib/map-center'; Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); @@ -124,7 +125,7 @@ function MapContent() { // Fallback: default US center when location hasn't arrived yet return { - centerCoordinate: [-98.5795, 39.8283] as [number, number], + centerCoordinate: [getDepartmentMapCenter().longitude, getDepartmentMapCenter().latitude] as [number, number], zoomLevel: 4, heading: 0, pitch: 0, diff --git a/src/app/call/new/__tests__/address-search.test.ts b/src/app/call/new/__tests__/address-search.test.ts index 2963e0ce..69d7cebc 100644 --- a/src/app/call/new/__tests__/address-search.test.ts +++ b/src/app/call/new/__tests__/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/app/call/new/__tests__/coordinates-search.test.tsx b/src/app/call/new/__tests__/coordinates-search.test.tsx index 9d3e09d9..bad22b3b 100644 --- a/src/app/call/new/__tests__/coordinates-search.test.tsx +++ b/src/app/call/new/__tests__/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/app/call/new/__tests__/plus-code-search.test.ts b/src/app/call/new/__tests__/plus-code-search.test.ts index 5bae52fb..da7452e7 100644 --- a/src/app/call/new/__tests__/plus-code-search.test.ts +++ b/src/app/call/new/__tests__/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/app/call/new/__tests__/what3words.test.tsx b/src/app/call/new/__tests__/what3words.test.tsx index 8d2a8da0..a0e4367a 100644 --- a/src/app/call/new/__tests__/what3words.test.tsx +++ b/src/app/call/new/__tests__/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/app/call/new/index.tsx b/src/app/call/new/index.tsx index f6b591f4..cda972fa 100644 --- a/src/app/call/new/index.tsx +++ b/src/app/call/new/index.tsx @@ -33,6 +33,8 @@ import { useToast } from '@/hooks/use-toast'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; import { type DispatchSelection } from '@/stores/dispatch/store'; +import { useNewCallFieldPolicy } from '@/hooks/use-new-call-field-policy'; +import { NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData'; // Define the form schema using zod const formSchema = z.object({ @@ -132,6 +134,10 @@ export default function NewCall() { 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; @@ -184,6 +190,30 @@ export default function NewCall() { const onSubmit = async (data: FormValues) => { try { + // 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. + // Form shapes differ slightly between the apps (not every one offers scheduling or a + // destination POI), so the optional fields are read through a loose view. + const policyValues = data as Record; + const missingFields = fieldPolicy.missingRequired({ + [NewCallFieldKeys.Address]: policyValues.address, + [NewCallFieldKeys.Geolocation]: data.latitude && data.longitude ? `${data.latitude},${data.longitude}` : '', + [NewCallFieldKeys.What3Words]: policyValues.what3words, + [NewCallFieldKeys.PlusCode]: policyValues.plusCode, + [NewCallFieldKeys.Note]: policyValues.note, + [NewCallFieldKeys.ContactName]: policyValues.contactName, + [NewCallFieldKeys.ContactInfo]: policyValues.contactInfo, + [NewCallFieldKeys.DestinationPoi]: policyValues.destinationPoiId, + [NewCallFieldKeys.DispatchOn]: policyValues.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) { + toast.error(t('calls.required_fields_missing', { fields: missingFields.join(', ') })); + return; + } + // If we have latitude and longitude, add them to the data if (selectedLocation?.latitude && selectedLocation?.longitude) { data.latitude = selectedLocation.latitude; @@ -713,26 +743,28 @@ export default function NewCall() { - - - - {t('calls.note')} - - ( - - )} - /> - - + {fieldPolicy.isVisible(NewCallFieldKeys.Note) ? ( + + + + {t('calls.note')} + + ( + + )} + /> + + + ) : null} {t('calls.call_location')} - + {/* Address Field */} @@ -755,7 +787,7 @@ export default function NewCall() { )} /> - + {/* GPS Coordinates Field */} @@ -778,7 +810,7 @@ export default function NewCall() { )} /> - + {/* what3words Field */} @@ -801,7 +833,7 @@ export default function NewCall() { )} /> - + {/* Plus Code Field */} @@ -824,7 +856,7 @@ export default function NewCall() { )} /> - + {/* Map Preview */} {selectedLocation ? ( @@ -835,7 +867,7 @@ export default function NewCall() { )} - + - - - - {t('calls.contact_name')} - - ( - - - - )} - /> - - + {fieldPolicy.isVisible(NewCallFieldKeys.ContactName) ? ( + + + + {t('calls.contact_name')} + + ( + + + + )} + /> + + + ) : null} diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index ed34a3d1..cbd14a35 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -59,6 +59,7 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde { 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/components/maps/full-screen-location-picker.tsx b/src/components/maps/full-screen-location-picker.tsx index 4d1ca4da..de36504b 100644 --- a/src/components/maps/full-screen-location-picker.tsx +++ b/src/components/maps/full-screen-location-picker.tsx @@ -10,15 +10,13 @@ import { Box } from '@/components/ui/box'; import { Button, ButtonText } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { Env } from '@/lib/env'; +import { getDepartmentMapCenter } from '@/lib/map-center'; // Ensure Mapbox access token is set before using any Mapbox components Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); -// Default location (center of USA) used when user location is unavailable -const DEFAULT_LOCATION = { - latitude: 39.8283, - longitude: -98.5795, -}; +// Falls back to the department's configured map center rather than a hardcoded point, so a +// department outside the US does not open every picker on the middle of Kansas. // Timeout for location fetching (in milliseconds) const LOCATION_TIMEOUT = 10000; @@ -41,7 +39,7 @@ const FullScreenLocationPicker: React.FC = ({ ini const [currentLocation, setCurrentLocation] = useState<{ latitude: number; longitude: number; - }>(initialLocation || DEFAULT_LOCATION); + }>(initialLocation || getDepartmentMapCenter()); const [isLocating, setIsLocating] = useState(false); const [isReverseGeocoding, setIsReverseGeocoding] = useState(false); const [address, setAddress] = useState(undefined); diff --git a/src/components/maps/location-picker.tsx b/src/components/maps/location-picker.tsx index ef2f23e4..14e4cfac 100644 --- a/src/components/maps/location-picker.tsx +++ b/src/components/maps/location-picker.tsx @@ -9,6 +9,7 @@ import { Box } from '@/components/ui/box'; import { Button, ButtonText } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { Env } from '@/lib/env'; +import { getDepartmentMapCenter } from '@/lib/map-center'; // Ensure Mapbox access token is set before using any Mapbox components if (!Env.UNIT_MAPBOX_PUBKEY) { @@ -17,11 +18,8 @@ if (!Env.UNIT_MAPBOX_PUBKEY) { Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); } -// Default location (center of USA) used when user location is unavailable -const DEFAULT_LOCATION = { - latitude: 39.8283, - longitude: -98.5795, -}; +// Falls back to the department's configured map center rather than a hardcoded point, so a +// department outside the US does not open every picker on the middle of Kansas. // Timeout for location fetching (in milliseconds) const LOCATION_TIMEOUT = 10000; @@ -44,7 +42,7 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca const [currentLocation, setCurrentLocation] = useState<{ latitude: number; longitude: number; - }>(initialLocation || DEFAULT_LOCATION); + }>(initialLocation || getDepartmentMapCenter()); const [isLocating, setIsLocating] = useState(false); const [hasUserLocation, setHasUserLocation] = useState(!!initialLocation); diff --git a/src/components/maps/map-view.web.tsx b/src/components/maps/map-view.web.tsx index 1e620804..ec8adbd0 100644 --- a/src/components/maps/map-view.web.tsx +++ b/src/components/maps/map-view.web.tsx @@ -10,6 +10,7 @@ import React, { forwardRef, useCallback, useContext, useEffect, useImperativeHan import { createRoot } from 'react-dom/client'; import { Env } from '@/lib/env'; +import { getDepartmentMapCenter } from '@/lib/map-center'; // Set the access token globally mapboxgl.accessToken = Env.UNIT_MAPBOX_PUBKEY; @@ -233,7 +234,7 @@ export const MapView = forwardRef( try { // Use initialCenter/initialZoom if provided so the map starts at the // correct position without needing a programmatic camera move later. - const startCenter = initialCenter && isFinite(initialCenter[0]) && isFinite(initialCenter[1]) ? initialCenter : ([-98.5795, 39.8283] as [number, number]); // Default US center + const startCenter = initialCenter && isFinite(initialCenter[0]) && isFinite(initialCenter[1]) ? initialCenter : ([getDepartmentMapCenter().longitude, getDepartmentMapCenter().latitude] as [number, number]); // Default US center const startZoom = initialZoom != null && isFinite(initialZoom) ? initialZoom : 4; const newMap = new mapboxgl.Map({ diff --git a/src/components/maps/pin-marker.tsx b/src/components/maps/pin-marker.tsx index 57ddd837..a8ec6af7 100644 --- a/src/components/maps/pin-marker.tsx +++ b/src/components/maps/pin-marker.tsx @@ -23,7 +23,8 @@ const PinMarker: React.FC = React.memo(({ imagePath, poiImage, t // with final fallback to default 'call' icon const resolvedPath = poiImage || imagePath; const iconKey = resolvedPath?.toLowerCase() as MapIconKey; - const icon = iconKey && MAP_ICONS[iconKey] ? MAP_ICONS[iconKey] : MAP_ICONS['call']; + // Unknown markers fall back to a neutral pin, not the call icon -- that one is a flame. + const icon = iconKey && MAP_ICONS[iconKey] ? MAP_ICONS[iconKey] : MAP_ICONS['flag']; return ( diff --git a/src/components/settings/__tests__/unit-selection-bottom-sheet-simple.test.tsx b/src/components/settings/__tests__/unit-selection-bottom-sheet-simple.test.tsx index 9669cf62..0b84c73d 100644 --- a/src/components/settings/__tests__/unit-selection-bottom-sheet-simple.test.tsx +++ b/src/components/settings/__tests__/unit-selection-bottom-sheet-simple.test.tsx @@ -255,10 +255,14 @@ describe('UnitSelectionBottomSheet', () => { units: mockUnits, fetchUnits: mockFetchUnits, isLoading: false, + error: null, + hasLoaded: true, } as any) : { units: mockUnits, fetchUnits: mockFetchUnits, isLoading: false, + error: null, + hasLoaded: true, } as any); mockUseToastStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ showToast: mockShowToast }) : { showToast: mockShowToast }); @@ -289,10 +293,14 @@ describe('UnitSelectionBottomSheet', () => { units: [], fetchUnits: jest.fn().mockResolvedValue(undefined), isLoading: true, + error: null, + hasLoaded: false, } as any) : { units: [], fetchUnits: jest.fn().mockResolvedValue(undefined), isLoading: true, + error: null, + hasLoaded: false, } as any); render(); @@ -306,10 +314,14 @@ describe('UnitSelectionBottomSheet', () => { units: [], fetchUnits: jest.fn().mockResolvedValue(undefined), isLoading: false, + error: null, + hasLoaded: true, } as any) : { units: [], fetchUnits: jest.fn().mockResolvedValue(undefined), isLoading: false, + error: null, + hasLoaded: true, } as any); render(); @@ -324,10 +336,14 @@ describe('UnitSelectionBottomSheet', () => { units: [], fetchUnits: spyFetchUnits, isLoading: false, + error: null, + hasLoaded: false, } as any) : { units: [], fetchUnits: spyFetchUnits, isLoading: false, + error: null, + hasLoaded: false, } as any); render(); @@ -445,14 +461,19 @@ describe('UnitSelectionBottomSheet', () => { const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { }); const errorFetchUnits = jest.fn().mockRejectedValue(new Error('Network error')); + // Nothing loaded yet, so opening the sheet triggers the fetch that then fails. mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ units: [], fetchUnits: errorFetchUnits, isLoading: false, + error: null, + hasLoaded: false, } as any) : { units: [], fetchUnits: errorFetchUnits, isLoading: false, + error: null, + hasLoaded: false, } as any); render(); diff --git a/src/components/settings/__tests__/unit-selection-bottom-sheet.test.tsx b/src/components/settings/__tests__/unit-selection-bottom-sheet.test.tsx index 7bbc1262..4b11acdb 100644 --- a/src/components/settings/__tests__/unit-selection-bottom-sheet.test.tsx +++ b/src/components/settings/__tests__/unit-selection-bottom-sheet.test.tsx @@ -6,6 +6,9 @@ jest.mock('react-i18next', () => ({ 'settings.select_unit': 'Select Unit', 'settings.current_unit': 'Current Unit', 'settings.no_units_available': 'No units available', + 'settings.no_units_available_help': 'No units are assigned to you.', + 'settings.units_load_failed': "Couldn't load units.", + 'common.retry': 'Retry', 'common.cancel': 'Cancel', 'settings.unit_selected_successfully': `${options?.unitName || 'Unit'} selected successfully`, 'settings.unit_selection_failed': 'Failed to select unit. Please try again.', @@ -316,10 +319,14 @@ describe('UnitSelectionBottomSheet', () => { units: mockUnits, fetchUnits: mockFetchUnits, isLoading: false, + error: null, + hasLoaded: true, } as any) : { units: mockUnits, fetchUnits: mockFetchUnits, isLoading: false, + error: null, + hasLoaded: true, } as any); // Mock the roles store @@ -381,19 +388,42 @@ describe('UnitSelectionBottomSheet', () => { }); it('displays empty state when no units available', () => { - mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ + const state = { units: [], fetchUnits: jest.fn().mockResolvedValue(undefined), isLoading: false, - } as any) : { + error: null, + hasLoaded: true, + }; + mockUseUnitsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(state as any) : (state as any))); + + render(); + + expect(screen.getByText('No units available')).toBeTruthy(); + }); + + it('shows a retry instead of the empty state when the load failed', async () => { + const retryFetch = jest.fn().mockResolvedValue(undefined); + const state = { units: [], - fetchUnits: jest.fn().mockResolvedValue(undefined), + fetchUnits: retryFetch, isLoading: false, - } as any); + error: 'Request failed with status code 401', + hasLoaded: true, + }; + mockUseUnitsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(state as any) : (state as any))); render(); - expect(screen.getByText('No units available')).toBeTruthy(); + expect(screen.getByText("Couldn't load units.")).toBeTruthy(); + expect(screen.queryByText('No units available')).toBeNull(); + + fireEvent.press(screen.getByTestId('retry-units-button')); + + await waitFor(() => { + // Retrying has to bypass the cache — the cached answer is what was wrong. + expect(retryFetch).toHaveBeenCalledWith(true); + }); }); it('fetches units when sheet opens and no units are loaded', async () => { @@ -403,10 +433,14 @@ describe('UnitSelectionBottomSheet', () => { units: [], fetchUnits: spyFetchUnits, isLoading: false, + error: null, + hasLoaded: false, } as any) : { units: [], fetchUnits: spyFetchUnits, isLoading: false, + error: null, + hasLoaded: false, } as any); render(); @@ -416,7 +450,7 @@ describe('UnitSelectionBottomSheet', () => { }); }); - it('does not fetch units when sheet opens and units are already loaded', () => { + it('does not fetch units when the sheet opens after a completed load', () => { render(); expect(mockFetchUnits).not.toHaveBeenCalled(); diff --git a/src/components/settings/language-item.tsx b/src/components/settings/language-item.tsx index bd008280..943539c3 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/components/settings/unit-selection-bottom-sheet.tsx b/src/components/settings/unit-selection-bottom-sheet.tsx index 7b237a34..dc2cdb3f 100644 --- a/src/components/settings/unit-selection-bottom-sheet.tsx +++ b/src/components/settings/unit-selection-bottom-sheet.tsx @@ -60,14 +60,17 @@ export const UnitSelectionBottomSheet = React.memo state.units); const fetchUnits = useUnitsStore((state) => state.fetchUnits); const isLoadingUnits = useUnitsStore((state) => state.isLoading); + const unitsError = useUnitsStore((state) => state.error); + const hasLoadedUnits = useUnitsStore((state) => state.hasLoaded); const activeUnit = useCoreStore((state) => state.activeUnit); const setActiveUnit = useCoreStore((state) => state.setActiveUnit); const showToast = useToastStore((state) => state.showToast); const isProcessingRef = React.useRef(false); - // Fetch units when sheet opens + // Fetch units when sheet opens. Keyed on hasLoaded rather than units.length: an empty list is a + // real answer, and re-fetching on every open turned a genuinely empty result into a request storm. React.useEffect(() => { - if (isOpen && units.length === 0) { + if (isOpen && !hasLoadedUnits) { fetchUnits().catch((error) => { logger.error({ message: 'Failed to fetch units', @@ -75,7 +78,17 @@ export const UnitSelectionBottomSheet = React.memo { + // Bypass the cache: a retry exists because the cached answer (or the lack of one) was wrong. + fetchUnits(true).catch((error) => { + logger.error({ + message: 'Failed to fetch units', + context: { error }, + }); + }); + }, [fetchUnits]); const handleClose = React.useCallback(() => { if (isLoading || isProcessingRef.current) { @@ -184,6 +197,18 @@ export const UnitSelectionBottomSheet = React.memo + ) : unitsError ? ( + // A failed load is not an empty department. Saying "no units available" when the + // request 401'd or timed out sends the crew to their administrator over a problem + // that a retry fixes. +
+ + {t('settings.units_load_failed')} + + +
) : units.length > 0 ? ( {units.map((unit) => ( @@ -192,7 +217,12 @@ export const UnitSelectionBottomSheet = React.memo ) : (
- {t('settings.no_units_available')} + + {t('settings.no_units_available')} + + {t('settings.no_units_available_help')} + +
)} 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..868a733d --- /dev/null +++ b/src/hooks/use-new-call-field-policy.ts @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { getNewCallFieldPolicy } from '@/api/calls/newCallFieldPolicy'; +import { logger } from '@/lib/logging'; +import { NewCallFieldKeys, type NewCallFieldKey, 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; + } + + 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/cache/__tests__/cache-manager.test.ts b/src/lib/cache/__tests__/cache-manager.test.ts index 5e426958..0d2e0827 100644 --- a/src/lib/cache/__tests__/cache-manager.test.ts +++ b/src/lib/cache/__tests__/cache-manager.test.ts @@ -14,11 +14,17 @@ jest.mock('@/lib/storage/app', () => ({ getBaseApiUrl: jest.fn(() => 'https://api.test'), })); +// Cache keys are scoped to the signed-in identity as well as the server, so a second user on the +// same device can never read the first user's cached rosters. +jest.mock('@/lib/cache/cache-scope', () => ({ + getCacheScopeKey: jest.fn(() => '7_user-1'), +})); + const mockedDelete = storage.delete as jest.Mock; const mockedGetAllKeys = storage.getAllKeys as jest.Mock; const mockedGetString = storage.getString as jest.Mock; -const CACHE_KEY = 'api_cache_https://api.test_/test'; +const CACHE_KEY = 'api_cache_https://api.test_7_user-1_/test'; describe('CacheManager', () => { beforeEach(() => { diff --git a/src/lib/cache/cache-manager.ts b/src/lib/cache/cache-manager.ts index 6fee1a2d..5f3d0783 100644 --- a/src/lib/cache/cache-manager.ts +++ b/src/lib/cache/cache-manager.ts @@ -2,6 +2,8 @@ import { logger } from '@/lib/logging'; import { storage } from '@/lib/storage'; import { getBaseApiUrl } from '@/lib/storage/app'; +import { getCacheScopeKey } from './cache-scope'; + interface CacheItem { data: T; timestamp: number; @@ -37,9 +39,11 @@ export class CacheManager { private getCacheKey(endpoint: string, params?: Record): string { const queryString = params ? `?${new URLSearchParams(params as Record)}` : ''; // Scope by server base URL so cached data from one environment is never - // served against another after a server-URL switch. + // served against another after a server-URL switch, and 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. const scope = getBaseApiUrl(); - return `api_cache_${scope}_${endpoint}${queryString}`; + return `api_cache_${scope}_${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..90b2f059 --- /dev/null +++ b/src/lib/map-center.ts @@ -0,0 +1,60 @@ +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): value is number => typeof value === 'number' && Number.isFinite(value) && value !== 0; + +const toMapCenter = (latitude: number | null | undefined, longitude: number | null | undefined, zoomLevel: number | null | undefined): MapCenter => { + // 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. + if (!isUsableCoordinate(latitude) || !isUsableCoordinate(longitude)) { + return FALLBACK_MAP_CENTER; + } + + return { + latitude, + longitude, + zoomLevel: typeof zoomLevel === 'number' && 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/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/stores/auth/store.tsx b/src/stores/auth/store.tsx index 2255c612..4283680f 100644 --- a/src/stores/auth/store.tsx +++ b/src/stores/auth/store.tsx @@ -13,6 +13,8 @@ import type { AuthResponse, AuthState, LoginCredentials, SsoLoginCredentials } f import { type ProfileModel } from '../../lib/auth/types'; import { getAuth } from '../../lib/auth/utils'; import { removeItem, setItem, zustandStorage } from '../../lib/storage'; +import { cacheManager } from '@/lib/cache/cache-manager'; +import { clearCacheScope, setCacheScope } from '@/lib/cache/cache-scope'; const useAuthStore = create()( persist( @@ -152,6 +154,9 @@ const useAuthStore = create()( status: 'signedOut', error: null, profile: null, + // Clearing the user id is what drops the API cache scope; leaving it set keeps this user's + // cache keys live for whoever signs in next on the same device. + userId: null, isFirstTime: true, refreshTimeoutId: null, }); @@ -392,4 +397,32 @@ const sanitizeJson = (json: string) => { return json.replace(/[\u0000]+/g, ''); }; +// 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(); + + if (state.userId) { + setCacheScope({ userId: state.userId }); + } else { + clearCacheScope(); + } + } catch (error) { + // Cache hygiene must never be able to break sign-in or sign-out. The scope is still correct + // in memory for this session, and a stale entry expires on its own. + logger.warn({ + message: 'Failed to reset the API cache scope on identity change', + context: { error }, + }); + } +}); + export default useAuthStore; diff --git a/src/stores/units/store.ts b/src/stores/units/store.ts index e10142ea..bd0db59f 100644 --- a/src/stores/units/store.ts +++ b/src/stores/units/store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { getAllUnitStatuses } from '@/api/satuses/statuses'; import { getUnits } from '@/api/units/units'; +import { logger } from '@/lib/logging'; import { type UnitTypeStatusResultData } from '@/models/v4/statuses/unitTypeStatusResultData'; import { type UnitResultData } from '@/models/v4/units/unitResultData'; @@ -10,7 +11,9 @@ interface UnitsState { unitStatuses: UnitTypeStatusResultData[]; isLoading: boolean; error: string | null; - fetchUnits: () => Promise; + /** True once a fetch has finished, so callers can tell "not loaded yet" from "loaded, none found". */ + hasLoaded: boolean; + fetchUnits: (forceRefresh?: boolean) => Promise; } export const useUnitsStore = create((set) => ({ @@ -18,14 +21,30 @@ export const useUnitsStore = create((set) => ({ unitStatuses: [], isLoading: false, error: null, - fetchUnits: async () => { + hasLoaded: false, + fetchUnits: async (forceRefresh = false) => { set({ isLoading: true, error: null }); try { - const unitsResponse = await getUnits(); + const unitsResponse = await getUnits(forceRefresh); const unitStatusesResponse = await getAllUnitStatuses(); - set({ units: unitsResponse.Data, unitStatuses: unitStatusesResponse.Data, isLoading: false }); + set({ + units: unitsResponse.Data ?? [], + unitStatuses: unitStatusesResponse.Data ?? [], + isLoading: false, + hasLoaded: true, + }); } catch (error) { - set({ error: 'Failed to fetch calls', isLoading: false }); + // The unit picker used to render this as "No units available", so a 401, a timeout or a server + // fault all looked to the crew like the department simply had no units. Keep the failure. + logger.error({ + message: 'Failed to fetch units', + context: { error }, + }); + set({ + error: error instanceof Error ? error.message : 'Failed to fetch units', + isLoading: false, + hasLoaded: true, + }); } }, })); diff --git a/src/translations/ar.json b/src/translations/ar.json index 30706d40..7eb6f0d2 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -312,7 +312,8 @@ "what3words_invalid_format": "تنسيق what3words غير صحيح. استخدم التنسيق: كلمة.كلمة.كلمة", "what3words_not_found": "لم يتم العثور على عنوان what3words، جرب عنوان آخر", "what3words_placeholder": "أدخل عنوان what3words (مثال: filled.count.soap)", - "what3words_required": "يرجى إدخال عنوان what3words للبحث" + "what3words_required": "يرجى إدخال عنوان what3words للبحث", + "required_fields_missing": "يطلب قسمك هذه الحقول قبل إنشاء البلاغ: {{fields}}" }, "chat": { "ack_pending_count": "لديك {{count}} رسائل عاجلة للإقرار بها", @@ -974,6 +975,7 @@ "general": "عام", "generale": "عام", "german": "الألمانية", + "greek": "اليونانية", "github": "جيثب", "help_center": "مركز المساعدة", "italian": "الإيطالية", @@ -990,6 +992,8 @@ "modern_notification_sounds_description": "استخدم مجموعة الأصوات الحديثة الجديدة لإشعارات الدفع. أوقف التشغيل لاستخدام الأصوات الكلاسيكية.", "more": "المزيد", "no_units_available": "لا توجد وحدات متاحة", + "no_units_available_help": "لم يتم تعيين أي وحدات لك. اطلب من مسؤول القسم إضافتك إلى مجموعة محطة إحدى الوحدات.", + "units_load_failed": "تعذر تحميل الوحدات. تحقق من اتصالك وحاول مرة أخرى.", "none_selected": "لم يتم اختيار أي شيء", "notifications": "الإشعارات", "notifications_description": "تفعيل الإشعارات لتلقي التنبيهات والتحديثات", diff --git a/src/translations/de.json b/src/translations/de.json index 1c3d6d74..06895707 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -312,7 +312,8 @@ "what3words_invalid_format": "Ungültiges what3words-Format. Bitte Format verwenden: wort.wort.wort", "what3words_not_found": "what3words-Adresse nicht gefunden, bitte eine andere Adresse versuchen", "what3words_placeholder": "what3words-Adresse eingeben (z. B. filled.count.soap)", - "what3words_required": "Bitte eine what3words-Adresse zum Suchen eingeben" + "what3words_required": "Bitte eine what3words-Adresse zum Suchen eingeben", + "required_fields_missing": "Ihre Abteilung benötigt diese Felder, bevor ein Einsatz angelegt werden kann: {{fields}}" }, "chat": { "ack_pending_count": "Sie haben {{count}} dringende Nachrichten zu bestätigen", @@ -974,6 +975,7 @@ "general": "Allgemein", "generale": "Allgemein", "german": "Deutsch", + "greek": "Griechisch", "github": "Github", "help_center": "Hilfecenter", "italian": "Italienisch", @@ -990,6 +992,8 @@ "modern_notification_sounds_description": "Verwende das neue moderne Sound-Set für Push-Benachrichtigungen. Deaktiviere diese Option, um die klassischen Töne zu verwenden.", "more": "Mehr", "no_units_available": "Keine Einheiten verfügbar", + "no_units_available_help": "Ihnen sind keine Einheiten zugewiesen. Bitten Sie Ihren Abteilungsadministrator, Sie der Stationsgruppe einer Einheit hinzuzufügen.", + "units_load_failed": "Einheiten konnten nicht geladen werden. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.", "none_selected": "Keine ausgewählt", "notifications": "Push-Benachrichtigungen", "notifications_description": "Benachrichtigungen aktivieren, um Warnungen und Updates zu erhalten", diff --git a/src/translations/el.json b/src/translations/el.json new file mode 100644 index 00000000..30319882 --- /dev/null +++ b/src/translations/el.json @@ -0,0 +1,1195 @@ +{ + "accessibility": { + "action": { + "address": "Ενεργοποιήστε για προβολή της διεύθυνσης στον χάρτη", + "email": "Ενεργοποιήστε για αποστολή email", + "phone": "Ενεργοποιήστε για κλήση του αριθμού τηλεφώνου", + "website": "Ενεργοποιήστε για άνοιγμα του ιστότοπου" + } + }, + "app": { + "title": "Resgrid Unit" + }, + "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", + "audio_output": "Έξοδος Ήχου", + "availableDevices": "Διαθέσιμες Συσκευές", + "available_devices": "Διαθέσιμες Συσκευές", + "bluetooth_not_ready": "Το Bluetooth είναι {{state}}. Ενεργοποιήστε το Bluetooth.", + "buttonControlAvailable": "Διαθέσιμος έλεγχος κουμπιών", + "checking": "Έλεγχος κατάστασης Bluetooth...", + "clear": "Καθαρισμός", + "connect": "Σύνδεση", + "connected": "Συνδεδεμένη", + "connectionError": "Σφάλμα Σύνδεσης", + "current_selection": "Τρέχουσα Επιλογή", + "device_disconnected": "Η συσκευή αποσυνδέθηκε", + "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": "Έλεγχος Μικροφώνου", + "system_audio": "Ήχος Συστήματος", + "system_audio_description": "Χρησιμοποιήστε το προεπιλεγμένο ηχείο και μικρόφωνο του τηλεφώνου σας", + "system_audio_error": "Δεν ήταν δυνατή η μετάβαση στον Ήχο Συστήματος", + "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": "Σφάλμα κατά τη λήψη εικόνων", + "error_capturing_image": "Αποτυχία λήψης εικόνας. Δοκιμάστε ξανά.", + "error_selecting_image": "Αποτυχία επιλογής εικόνας. Δοκιμάστε ξανά.", + "failed_to_load": "Αποτυχία φόρτωσης εικόνας", + "image_alt": "Εικόνα κλήσης", + "image_name": "Όνομα Εικόνας", + "image_note": "Σημείωση Εικόνας", + "loading": "Φόρτωση...", + "no_images": "Δεν υπάρχουν διαθέσιμες εικόνες", + "no_images_description": "Προσθέστε εικόνες στην κλήση σας για να βοηθήσετε στην τεκμηρίωση και την επικοινωνία", + "select_from_gallery": "Επιλογή από τη Συλλογή", + "take_photo": "Λήψη Φωτογραφίας", + "title": "Εικόνες Κλήσης", + "upload": "Μεταφόρτωση" + }, + "callNotes": { + "addNote": "Προσθήκη Σημείωσης", + "addNoteLabel": "Προσθήκη Νέας Σημείωσης", + "addNotePlaceholder": "Προσθέστε νέα σημείωση...", + "noNotes": "Δεν υπάρχουν διαθέσιμες σημειώσεις για αυτή την κλήση", + "noNotesFound": "Δεν βρέθηκαν σημειώσεις", + "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": "Προορισμός", + "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": "Η κλήση δεν βρέθηκε", + "note": "Σημείωση", + "notes": "Σημειώσεις", + "priority": "Προτεραιότητα", + "reference_id": "Αναγνωριστικό Αναφοράς", + "set_active": "Ορισμός ως Ενεργή", + "set_active_error": "Αποτυχία ορισμού της κλήσης ως ενεργής", + "set_active_success": "Η κλήση ορίστηκε ως ενεργή", + "setting_active": "Ορισμός ως Ενεργή...", + "status": "Κατάσταση", + "tabs": { + "check_in": "Παρουσία", + "contact": "Επαφή", + "dispatched": "Απεσταλμένες", + "info": "Πληροφορίες", + "protocols": "Πρωτόκολλα", + "timeline": "Δραστηριότητα" + }, + "timestamp": "Χρονική Σήμανση", + "title": "Λεπτομέρειες Κλήσης", + "type": "Τύπος", + "unit": "Μονάδα", + "update_call_error": "Αποτυχία ενημέρωσης κλήσης", + "update_call_success": "Η κλήση ενημερώθηκε με επιτυχία" + }, + "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": "Σημείο Ενδιαφέροντος Προορισμού", + "destination_poi_none": "Δεν επιλέχθηκε προορισμός", + "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_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_destination_poi": "Επιλογή Σημείου Ενδιαφέροντος Προορισμού", + "select_dispatch_recipients": "Επιλογή Παραληπτών Αποστολής", + "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 για αναζήτηση", + "required_fields_missing": "Το τμήμα σας απαιτεί αυτά τα πεδία πριν δημιουργηθεί κλήση: {{fields}}" + }, + "chat": { + "ack_pending_count": "Έχετε {{count}} επείγοντα μηνύματα προς επιβεβαίωση", + "ack_pending_one": "Έχετε ένα επείγον μήνυμα προς επιβεβαίωση", + "ack_required": "Απαιτείται επιβεβαίωση", + "acknowledge": "Επιβεβαίωση", + "add_gif": "Προσθήκη GIF", + "add_image": "Προσθήκη εικόνας", + "are_typing": "{{count}} άτομα πληκτρολογούν...", + "assistant": "Βοηθός", + "attachment_failed": "Η μεταφόρτωση του συνημμένου απέτυχε", + "channel": "Κανάλι", + "chatting_as": "Συνομιλείτε ως {{name}}", + "copied": "Αντιγράφηκε", + "copy": "Αντιγραφή", + "copy_unavailable": "Η αντιγραφή δεν είναι διαθέσιμη σε αυτή τη συσκευή", + "create_conversation_failed": "Δεν ήταν δυνατή η έναρξη της συνομιλίας", + "create_group_with": "Δημιουργία ομάδας ({{count}})", + "delete": "Διαγραφή", + "delete_failed": "Δεν ήταν δυνατή η διαγραφή του μηνύματος", + "direct_message": "Άμεσο Μήνυμα", + "edit": "Επεξεργασία", + "edit_failed": "Δεν ήταν δυνατή η επεξεργασία του μηνύματος", + "edit_message": "Επεξεργασία μηνύματος", + "edited": "(επεξεργάστηκε)", + "emoji": "Emoji", + "empty": "Δεν υπάρχουν ακόμη συνομιλίες. Ξεκινήστε ένα άμεσο μήνυμα ή δημιουργήστε μια ομάδα.", + "failed_tap_retry": "Απέτυχε - πατήστε για επανάληψη", + "flag": "Αναφορά", + "flag_failed": "Δεν ήταν δυνατή η αναφορά του μηνύματος", + "flag_harassment": "Παρενόχληση", + "flag_inappropriate": "Ακατάλληλο", + "flag_other": "Άλλο", + "flag_policy": "Παραβίαση πολιτικής", + "flag_reason": "Γιατί το αναφέρετε αυτό;", + "flag_sensitive": "Ευαίσθητες πληροφορίες", + "flag_spam": "Ανεπιθύμητο", + "group_name": "Όνομα ομάδας (προαιρετικό)", + "is_typing": "Ο/Η {{name}} πληκτρολογεί...", + "load_people_failed": "Δεν ήταν δυνατή η φόρτωση ατόμων", + "message_deleted": "Αυτό το μήνυμα διαγράφηκε", + "moderator_delete": "Αφαίρεση (συντονιστής)", + "moderator_removed": "Αφαιρέθηκε από συντονιστή", + "new_direct_message": "Νέο Άμεσο Μήνυμα", + "new_group": "Νέα Ομάδα", + "no_gifs": "Δεν βρέθηκαν GIF", + "no_people": "Δεν βρέθηκαν άτομα", + "open_assistant": "Άνοιγμα Βοηθού", + "original_message": "Αρχικό μήνυμα", + "permission_location_denied": "Η άδεια τοποθεσίας απορρίφθηκε", + "permission_photos_denied": "Η άδεια βιβλιοθήκης φωτογραφιών απορρίφθηκε", + "pin": "Καρφίτσωμα", + "pin_failed": "Δεν ήταν δυνατή η ενημέρωση του καρφιτσώματος", + "reaction_failed": "Δεν ήταν δυνατή η ενημέρωση της αντίδρασης", + "reply_in_thread": "Απάντηση στο νήμα", + "reply_placeholder": "Απάντηση...", + "save": "Αποθήκευση", + "search_gifs": "Αναζήτηση GIF", + "search_people": "Αναζήτηση ατόμων", + "section_assistant": "Βοηθός", + "section_channels": "Κανάλια", + "section_direct_messages": "Άμεσα Μηνύματα", + "section_incidents": "Περιστατικά", + "send": "Αποστολή", + "share_location": "Κοινή χρήση τοποθεσίας", + "shared_location": "Κοινοποιημένη τοποθεσία", + "someone": "Κάποιος", + "thread": "Νήμα", + "thread_replies": "{{count}} απαντήσεις", + "title": "Συνομιλία", + "type_a_message": "Πληκτρολογήστε ένα μήνυμα", + "unpin": "Ξεκαρφίτσωμα", + "urgent": "Επείγον", + "urgent_will_send": "Αυτό το μήνυμα θα σταλεί ως επείγον" + }, + "chatbot": { + "ask_placeholder": "Ρωτήστε τον βοηθό...", + "empty": "Ρωτήστε τον βοηθό οτιδήποτε για να ξεκινήσετε.", + "new_session": "Νέα συνεδρία", + "subtitle": "Βοηθός AI για το τμήμα σας", + "title": "Βοηθός" + }, + "check_in": { + "add_note": "Προσθήκη σημείωσης", + "check_in_error": "Αποτυχία καταγραφής παρουσίας", + "check_in_success": "Η παρουσία καταγράφηκε με επιτυχία", + "confirm": "Επιβεβαίωση Παρουσίας", + "duration": "λεπ.", + "elapsed": "Παρήλθαν", + "history": "Ιστορικό", + "last_check_in": "Τελευταία παρουσία", + "minutes_ago": "λεπ. πριν", + "no_timers": "Δεν υπάρχουν ενεργά χρονόμετρα παρουσίας", + "notification_channel_description": "Ειδοποιήσεις χρονομέτρου για δηλώσεις παρουσίας κλήσεων", + "notification_channel_name": "Χρονόμετρα Παρουσίας", + "perform_check_in": "Δήλωση Παρουσίας", + "queued_offline": "Η δήλωση παρουσίας τέθηκε σε ουρά για όταν αποκατασταθεί η σύνδεση", + "quick_check_in": "Γρήγορη Δήλωση Παρουσίας", + "select_type": "Επιλέξτε τύπο παρουσίας", + "status_critical": "Κρίσιμο", + "status_ok": "Εντάξει", + "status_overdue": "Εκπρόθεσμο", + "status_warning": "Προειδοποίηση", + "tab_title": "Παρουσία", + "timer_status": "Κατάσταση Χρονομέτρου", + "timers_disabled": "Τα χρονόμετρα παρουσίας είναι απενεργοποιημένα για αυτή την κλήση", + "type_hazmat": "Επικίνδυνα Υλικά", + "type_ic": "Διοίκηση Συμβάντος", + "type_par": "PAR", + "type_personnel": "Προσωπικό", + "type_rehab": "Αποκατάσταση", + "type_sector_rotation": "Εναλλαγή Τομέα", + "type_unit": "Μονάδα" + }, + "common": { + "add": "Προσθήκη", + "back": "Πίσω", + "cancel": "Ακύρωση", + "close": "Κλείσιμο", + "confirm": "Επιβεβαίωση", + "confirm_location": "Επιβεβαίωση Τοποθεσίας", + "delete": "Διαγραφή", + "dismiss": "Κλείσιμο", + "done": "Ολοκληρώθηκε", + "edit": "Επεξεργασία", + "error": "Σφάλμα", + "errorOccurred": "Παρουσιάστηκε σφάλμα", + "get_my_location": "Λήψη Τοποθεσίας Μου", + "go_back": "Επιστροφή", + "loading": "Φόρτωση...", + "loading_address": "Φόρτωση διεύθυνσης...", + "my_location": "Η Τοποθεσία Μου", + "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": "Αναζήτηση...", + "select_option": "Επιλέξτε επιλογή", + "selected_location": "Επιλεγμένη Τοποθεσία", + "set_location": "Ορισμός Τοποθεσίας", + "share": "Κοινή χρήση", + "step": "Βήμα", + "submit": "Υποβολή", + "submitting": "Υποβολή...", + "tap_map_to_select": "Πατήστε στον χάρτη για να επιλέξετε τοποθεσία", + "tryAgainLater": "Δοκιμάστε ξανά αργότερα", + "unknown": "Άγνωστο", + "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": "Ταχυδρομικός Κώδικας" + }, + "form": { + "invalid_url": "Εισαγάγετε έγκυρη διεύθυνση URL που ξεκινά με http:// ή https://", + "required": "Αυτό το πεδίο είναι υποχρεωτικό" + }, + "incident_command": { + "action_plan": "Σχέδιο Δράσης Περιστατικού", + "assigned_since": "Ανατέθηκε {{time}}", + "attachments": "Συνημμένα", + "chat": "Συνομιλία", + "chat_frozen": "Αυτό το περιστατικό έχει κλείσει. Οι συνομιλίες διατηρούνται ως αρχείο δεδομένης χρονικής στιγμής — δεν επιτρέπονται νέα μηνύματα.", + "command_channel": "Συνομιλία διοίκησης", + "command_channel_hint": "Μόνο επιτελείο διοίκησης", + "commander": "Διοικητής", + "dispatch_channel": "Κέντρο Επιχειρήσεων", + "dispatch_channel_hint": "Επικοινωνήστε με το κέντρο επιχειρήσεων", + "dm_failed": "Δεν ήταν δυνατό το άνοιγμα αυτής της συνομιλίας.", + "dm_unavailable": "Αυτή η επαφή δεν έχει λογαριασμό Resgrid για αποστολή μηνύματος.", + "error": "Αποτυχία φόρτωσης πληροφοριών διοίκησης περιστατικού", + "established": "Εγκαταστάθηκε", + "estimated_end": "Εκτιμώμενη Λήξη", + "ics_positions": "Θέσεις ICS", + "important_information": "Σημαντικές Πληροφορίες", + "incident_channel": "Συνομιλία περιστατικού", + "incident_info": "Πληροφορίες Περιστατικού", + "lane_channel": "Συνομιλία τομέα", + "leads_channel": "Όλοι οι Επικεφαλής", + "leads_channel_hint": "Διοικητής και επικεφαλής τομέων", + "linked_need": "Συνδεδεμένη Ανάγκη", + "message_person": "Μήνυμα προς {{name}}", + "my_assignment": "Ανάθεση Μονάδας", + "need_category": { + "equipment": "Εξοπλισμός", + "logistics": "Εφοδιαστική", + "medical": "Ιατρικά", + "other": "Άλλο", + "resource": "Πόρος", + "staffing": "Στελέχωση" + }, + "need_status": { + "cancelled": "Ακυρώθηκε", + "met": "Καλύφθηκε", + "open": "Ανοιχτή", + "partially_met": "Μερικώς Καλυμμένη" + }, + "needs": "Ανάγκες", + "no_active_command": "Δεν υπάρχει ενεργή διοίκηση περιστατικού για αυτή την κλήση", + "no_attachments": "Χωρίς συνημμένα", + "no_needs": "Χωρίς ανάγκες", + "no_notes": "Χωρίς σημειώσεις", + "no_objectives": "Χωρίς στόχους", + "notes": "Σημειώσεις", + "objective_status": { + "complete": "Ολοκληρωμένος", + "in_progress": "Σε Εξέλιξη", + "pending": "Εκκρεμής" + }, + "objective_type": { + "benchmark": "Ορόσημο", + "general": "Γενικός", + "safety": "Ασφάλεια" + }, + "objectives": "Στόχοι", + "primary_lead": "Κύριος Επικεφαλής", + "primary_objective": "Κύριος Στόχος", + "progress": "{{percent}}% ολοκληρωμένο", + "quantity_fulfilled": "{{fulfilled}} από {{requested}} καλύφθηκαν", + "reach_directly": "Άμεση επικοινωνία", + "role_generic": "Ρόλος ICS {{role}}", + "secondary_lead": "Δευτερεύων Επικεφαλής", + "secondary_objective": "Δευτερεύων Στόχος", + "send_message": "Μήνυμα", + "tab_title": "Διοίκηση" + }, + "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": "Επεξεργασία του αιτήματός σας..." + }, + "login": { + "errorModal": { + "confirmButton": "Εντάξει", + "message": "Ελέγξτε το όνομα χρήστη και τον κωδικό πρόσβασής σας και δοκιμάστε ξανά.", + "title": "Η Σύνδεση Απέτυχε" + }, + "login": "Σύνδεση", + "login_button": "Σύνδεση", + "login_button_description": "Συνδεθείτε στον λογαριασμό σας για να συνεχίσετε", + "login_button_error": "Σφάλμα κατά τη σύνδεση", + "login_button_loading": "Σύνδεση...", + "login_button_success": "Επιτυχής σύνδεση", + "or_sign_in_with_password": "ή συνδεθείτε με κωδικό πρόσβασης", + "password": "Κωδικός Πρόσβασης", + "password_incorrect": "Ο κωδικός πρόσβασης ήταν λανθασμένος", + "password_placeholder": "Εισαγάγετε τον κωδικό πρόσβασής σας", + "sso_button": "Συνδεθείτε με SSO", + "sso_department": "Το SSO παρέχεται από {{name}}", + "sso_department_id": "Αναγνωριστικό Τμήματος (προαιρετικό)", + "sso_department_id_placeholder": "Εισαγάγετε το αναγνωριστικό του τμήματός σας", + "sso_error_message": "Δεν ήταν δυνατή η ολοκλήρωση της σύνδεσης SSO. Δοκιμάστε ξανά.", + "sso_error_title": "Σφάλμα SSO", + "sso_not_found": "Δεν βρέθηκε διαμόρφωση SSO για αυτό το όνομα χρήστη", + "sso_signing_in": "Σύνδεση με SSO...", + "sso_subtitle": "Εισαγάγετε το όνομα χρήστη σας για να αναζητήσετε τη διαμόρφωση SSO σας", + "sso_title": "Σύνδεση με SSO", + "subtitle": "Εισαγάγετε τα διαπιστευτήριά σας για πρόσβαση στο Resgrid Unit", + "title": "Σύνδεση", + "username": "Όνομα Χρήστη", + "username_not_found": "Το όνομα χρήστη δεν βρέθηκε", + "username_placeholder": "Εισαγάγετε το όνομα χρήστη σας" + }, + "map": { + "call_set_as_current": "Η κλήση ορίστηκε ως τρέχουσα κλήση", + "failed_to_open_maps": "Αποτυχία ανοίγματος εφαρμογής χαρτών", + "failed_to_set_current_call": "Αποτυχία ορισμού της κλήσης ως τρέχουσας", + "no_location_for_routing": "Δεν υπάρχουν διαθέσιμα δεδομένα τοποθεσίας για δρομολόγηση", + "pin_address": "Διεύθυνση", + "pin_color": "Χρώμα Πινέζας", + "pin_note": "Σημείωση", + "pin_type": "Τύπος Σημείου Ενδιαφέροντος", + "recenter_map": "Επανακεντράρισμα Χάρτη", + "set_as_current_call": "Ορισμός ως Τρέχουσα Κλήση", + "view_call_details": "Προβολή Λεπτομερειών Κλήσης", + "view_poi_details": "Προβολή Λεπτομερειών Σημείου Ενδιαφέροντος" + }, + "maps": { + "active_layers": "Ενεργά Επίπεδα", + "all": "Όλα", + "custom": "Προσαρμοσμένο", + "custom_maps": "Προσαρμοσμένοι Χάρτες", + "custom_region": "Προσαρμοσμένη Περιοχή", + "dispatch_to_zone": "Αποστολή σε αυτή τη ζώνη", + "error_loading": "Αποτυχία φόρτωσης δεδομένων χάρτη", + "event": "Συμβάν", + "feature": "Χαρακτηριστικό", + "floor": "Όροφος", + "floor_count": "{{count}} όροφοι", + "floor_count_one": "{{count}} όροφος", + "general": "Γενικά", + "indoor": "Εσωτερικός Χώρος", + "indoor_maps": "Χάρτες Εσωτερικών Χώρων", + "indoor_zone": "Ζώνη Εσωτερικού Χώρου", + "layer_toggles": "Διακόπτες Επιπέδων", + "layers_on": "{{active}} / {{total}} ενεργά", + "loading": "Φόρτωση χαρτών...", + "no_maps": "Χωρίς Χάρτες", + "no_maps_description": "Δεν υπάρχουν διαθέσιμοι χάρτες για το τμήμα σας.", + "no_results": "Δεν βρέθηκαν αποτελέσματα", + "outdoor": "Εξωτερικός Χώρος", + "region": "Περιοχή", + "search": "Αναζήτηση χαρτών...", + "search_maps": "Αναζήτηση Χαρτών", + "search_placeholder": "Αναζήτηση για τοποθεσίες, ζώνες, περιοχές...", + "title": "Χάρτες", + "zone": "Ζώνη", + "zone_details": "Λεπτομέρειες Ζώνης" + }, + "notes": { + "actions": { + "add": "Προσθήκη Σημείωσης", + "delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτή τη σημείωση;" + }, + "details": { + "close": "Κλείσιμο", + "created": "Δημιουργήθηκε", + "delete": "Διαγραφή", + "edit": "Επεξεργασία", + "tags": "Ετικέτες", + "title": "Λεπτομέρειες Σημείωσης", + "updated": "Ενημερώθηκε" + }, + "empty": "Δεν βρέθηκαν σημειώσεις", + "emptyDescription": "Δεν έχουν δημιουργηθεί ακόμη σημειώσεις για το τμήμα σας.", + "search": "Αναζήτηση σημειώσεων...", + "title": "Σημειώσεις" + }, + "onboarding": { + "message": "Καλώς ήρθατε στον ιστότοπο της εφαρμογής obytes" + }, + "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": "Προβολή Κλήσης" + }, + "roles": { + "modal": { + "title": "Αναθέσεις Ρόλων Μονάδας" + }, + "selectUser": "Επιλογή χρήστη", + "status": "{{active}} από {{total}} Ρόλους", + "tap_to_manage": "Πατήστε για διαχείριση ρόλων", + "unassigned": "Χωρίς ανάθεση" + }, + "routes": { + "acknowledge": "Επιβεβαίωση", + "active": "Ενεργή", + "active_route": "Ενεργή Διαδρομή", + "address": "Διεύθυνση", + "all_routes": "Όλες οι Διαδρομές", + "assigned_other_unit": "Αυτή η διαδρομή έχει ανατεθεί σε άλλη μονάδα και δεν μπορεί να ξεκινήσει από αυτή τη μονάδα", + "cancel_route": "Ακύρωση Διαδρομής", + "cancel_route_confirm": "Είστε βέβαιοι ότι θέλετε να ακυρώσετε αυτή τη διαδρομή;", + "check_in": "Άφιξη", + "check_out": "Αναχώρηση", + "clear_filters": "Καθαρισμός φίλτρων", + "completed": "Ολοκληρώθηκε", + "contact": "Επαφή", + "contact_details": "Λεπτομέρειες Επαφής", + "current_step": "Τρέχον Βήμα", + "delay": "καθυστέρηση", + "description": "Περιγραφή", + "destination": "Προορισμός Διαδρομής", + "deviation": "Απόκλιση Διαδρομής", + "deviation_type_missed_stop": "Χαμένη Στάση", + "deviation_type_off_route": "Εκτός Διαδρομής", + "deviation_type_other": "Άλλο", + "deviation_type_speed_violation": "Παράβαση Ταχύτητας", + "deviation_type_unexpected_stop": "Απροσδόκητη Στάση", + "deviations": "Αποκλίσεις", + "directions": "Οδηγίες", + "distance": "Απόσταση", + "driving_conditions": "Συνθήκες Οδήγησης", + "duration": "Διάρκεια", + "dwell_time": "Χρόνος Παραμονής", + "end": "Λήξη", + "end_route": "Τερματισμός Διαδρομής", + "end_route_confirm": "Είστε βέβαιοι ότι θέλετε να τερματίσετε αυτή τη διαδρομή;", + "entrance": "Είσοδος", + "estimated_distance": "Εκτιμώμενη Απόσταση", + "estimated_duration": "Εκτιμώμενη Διάρκεια", + "eta": "Εκτιμώμενη Ώρα Άφιξης", + "eta_to_next": "Εκτιμώμενη ώρα άφιξης στην επόμενη στάση", + "exit": "Έξοδος", + "failed_to_open_poi_maps": "Αποτυχία ανοίγματος χαρτών για αυτό το σημείο ενδιαφέροντος", + "fetching_directions": "Λήψη οδηγιών οδήγησης...", + "geofence_radius": "Ακτίνα Γεωφράχτη", + "history": "Ιστορικό Διαδρομών", + "in_progress": "Σε Εξέλιξη", + "instance_detail": "Εκτέλεση Διαδρομής", + "loading": "Φόρτωση διαδρομών...", + "loading_directions": "Φόρτωση οδηγιών...", + "loading_poi": "Φόρτωση σημείου ενδιαφέροντος...", + "loading_pois": "Φόρτωση σημείων ενδιαφέροντος...", + "loading_stops": "Φόρτωση στάσεων...", + "location": "Τοποθεσία", + "min": "λεπ.", + "my_unit": "Η Μονάδα Μου", + "next_step": "Επόμενο Βήμα", + "no_contact": "Δεν υπάρχουν διαθέσιμα στοιχεία επικοινωνίας", + "no_deviations": "Δεν καταγράφηκαν αποκλίσεις", + "no_directions": "Δεν υπάρχουν διαθέσιμες οδηγίες", + "no_history": "Δεν υπάρχει διαθέσιμο ιστορικό διαδρομών", + "no_history_description": "Οι ολοκληρωμένες διαδρομές θα εμφανίζονται εδώ.", + "no_pois": "Χωρίς Σημεία Ενδιαφέροντος", + "no_pois_description": "Δεν υπάρχουν διαθέσιμα σημεία ενδιαφέροντος για το τμήμα σας.", + "no_pois_filtered_description": "Κανένα σημείο ενδιαφέροντος δεν ταιριάζει με τα τρέχοντα φίλτρα.", + "no_routes": "Χωρίς Διαδρομές", + "no_routes_description": "Δεν υπάρχουν διαθέσιμα σχέδια διαδρομής για τη μονάδα σας.", + "no_routes_description_all": "Δεν υπάρχουν διαθέσιμα σχέδια διαδρομής.", + "no_search_results": "Δεν βρέθηκαν διαδρομές", + "no_search_results_pois": "Δεν βρέθηκαν σημεία ενδιαφέροντος", + "no_stops": "Δεν υπάρχουν διαθέσιμες στάσεις", + "notes": "Σημειώσεις", + "notes_placeholder": "Εισαγάγετε σημειώσεις για αυτή τη στάση...", + "open_in_maps": "Άνοιγμα στους Χάρτες", + "pause_route": "Παύση Διαδρομής", + "paused": "Σε Παύση", + "pending": "Εκκρεμής", + "planned_arrival": "Προγραμματισμένη Άφιξη", + "planned_departure": "Προγραμματισμένη Αναχώρηση", + "poi_address": "Διεύθυνση", + "poi_coordinates": "Συντεταγμένες", + "poi_coordinates_compact": "{{latitude}}, {{longitude}}", + "poi_coordinates_value": "Πλάτος {{latitude}}, Μήκος {{longitude}}", + "poi_destination_enabled": "Ο προορισμός είναι ενεργοποιημένος", + "poi_detail": "Λεπτομέρειες Σημείου Ενδιαφέροντος", + "poi_filter_all_types": "Όλοι οι τύποι σημείων ενδιαφέροντος", + "poi_filter_placeholder": "Φιλτράρισμα κατά τύπο", + "poi_filter_type": "Τύπος Σημείου Ενδιαφέροντος", + "poi_filters": "Φίλτρα", + "poi_not_found": "Το σημείο ενδιαφέροντος δεν βρέθηκε", + "poi_not_found_description": "Δεν ήταν δυνατή η εύρεση αυτού του σημείου ενδιαφέροντος.", + "poi_note": "Σημείωση", + "poi_sort_by": "Ταξινόμηση Κατά", + "poi_sort_display": "Όνομα", + "poi_sort_placeholder": "Ταξινόμηση σημείων ενδιαφέροντος", + "poi_sort_type": "Τύπος", + "poi_type_unknown": "Άγνωστος τύπος", + "pois_tab": "Σημεία Ενδιαφέροντος", + "priority": "Προτεραιότητα", + "priority_critical": "Κρίσιμη", + "priority_high": "Υψηλή", + "priority_low": "Χαμηλή", + "priority_medium": "Μεσαία", + "priority_normal": "Κανονική", + "progress": "{{percent}}% ολοκληρωμένο", + "pull_to_refresh": "Τραβήξτε προς τα κάτω για ανανέωση", + "remaining_steps": "Υπόλοιπα Βήματα", + "resume_route": "Συνέχιση Διαδρομής", + "route_summary": "Σύνοψη Διαδρομής", + "route_to_poi": "Διαδρομή προς Σημείο Ενδιαφέροντος", + "routes_tab": "Διαδρομές", + "schedule": "Πρόγραμμα", + "search": "Αναζήτηση διαδρομών...", + "search_pois": "Αναζήτηση σημείων ενδιαφέροντος...", + "select_unit": "Επιλογή Μονάδας", + "set_poi_destination": "Ορισμός Προορισμού", + "skip": "Παράλειψη", + "skip_reason": "Λόγος Παράλειψης", + "skip_reason_placeholder": "Εισαγάγετε τον λόγο παράλειψης αυτής της στάσης", + "skipped": "Παραλείφθηκε", + "skipped_by_driver": "Παραλείφθηκε από τον οδηγό", + "start": "Έναρξη", + "start_route": "Έναρξη Διαδρομής", + "step_of": "Βήμα {{current}} από {{total}}", + "stop_contact": "Επαφή Στάσης", + "stop_count": "{{count}} στάσεις", + "stop_count_one": "{{count}} στάση", + "stop_detail": "Λεπτομέρειες Στάσης", + "stop_type_dropoff": "Παράδοση", + "stop_type_inspection": "Επιθεώρηση", + "stop_type_pickup": "Παραλαβή", + "stop_type_service": "Εξυπηρέτηση", + "stop_type_standard": "Τυπική", + "stops": "Στάσεις", + "stops_completed": "Ολοκληρωμένες Στάσεις", + "summary_stats": "Σύνοψη", + "title": "Διαδρομές", + "total": "Σύνολο", + "total_distance": "Συνολική Απόσταση", + "total_duration": "Συνολική Διάρκεια", + "traffic_delay": "Καθυστέρηση {{time}} λόγω κυκλοφορίας", + "try_different_search": "Δοκιμάστε διαφορετικό όρο αναζήτησης", + "type": "Τύπος", + "unassigned": "Χωρίς ανάθεση", + "unit": "Μονάδα", + "unit_required": "Πρέπει να επιλεγεί μονάδα για να ξεκινήσει η διαδρομή", + "view_contact": "Προβολή Επαφής", + "view_on_map": "Προβολή στον χάρτη", + "view_route": "Προβολή Διαδρομής" + }, + "settings": { + "about": "Σχετικά", + "account": "Λογαριασμός", + "activating_unit": "Ενεργοποίηση μονάδας...", + "active_unit": "Ενεργή Μονάδα", + "app_info": "Πληροφορίες Εφαρμογής", + "app_name": "Όνομα Εφαρμογής", + "arabic": "Αραβικά", + "audio_device_selection": { + "bluetooth_device": "Συσκευή Bluetooth", + "current_selection": "Τρέχουσα Επιλογή", + "default_microphone": "Προεπιλεγμένο Μικρόφωνο", + "default_speaker": "Προεπιλεγμένο Ηχείο", + "microphone": "Μικρόφωνο", + "no_microphones_available": "Δεν υπάρχουν διαθέσιμα μικρόφωνα", + "no_speakers_available": "Δεν υπάρχουν διαθέσιμα ηχεία", + "none_selected": "Καμία επιλογή", + "speaker": "Ηχείο", + "speaker_device": "Συσκευή Ηχείου", + "system_audio": "Ήχος Συστήματος", + "title": "Επιλογή Συσκευής Ήχου", + "unavailable": "Μη διαθέσιμη", + "wired_device": "Ενσύρματη Συσκευή" + }, + "background_geolocation": "Γεωεντοπισμός στο Παρασκήνιο", + "background_geolocation_warning": "Αυτή η λειτουργία επιτρέπει στην εφαρμογή να παρακολουθεί την τοποθεσία σας στο παρασκήνιο. Βοηθά στον συντονισμό της ανταπόκρισης έκτακτης ανάγκης, αλλά μπορεί να επηρεάσει τη διάρκεια της μπαταρίας.", + "background_location": "Τοποθεσία στο Παρασκήνιο", + "contact_us": "Επικοινωνήστε Μαζί Μας", + "current_unit": "Τρέχουσα Μονάδα", + "english": "Αγγλικά", + "enter_password": "Εισαγάγετε τον κωδικό πρόσβασής σας", + "enter_server_url": "Εισαγάγετε τη διεύθυνση URL του Resgrid API (π.χ., https://api.resgrid.com)", + "enter_username": "Εισαγάγετε το όνομα χρήστη σας", + "environment": "Περιβάλλον", + "french": "Γαλλικά", + "general": "Γενικά", + "generale": "Γενικά", + "german": "Γερμανικά", + "greek": "Ελληνικά", + "github": "Github", + "help_center": "Κέντρο Βοήθειας", + "italian": "Ιταλικά", + "keep_alive": "Διατήρηση Ενεργού", + "keep_alive_warning": "Προειδοποίηση: Η ενεργοποίηση της διατήρησης ενεργού θα εμποδίσει τη συσκευή σας να μεταβεί σε αδράνεια και ενδέχεται να αυξήσει σημαντικά την κατανάλωση μπαταρίας.", + "keep_screen_on": "Διατήρηση Οθόνης Ενεργής", + "language": "Γλώσσα", + "links": "Σύνδεσμοι", + "login_info": "Στοιχεία Σύνδεσης", + "logout": "Αποσύνδεση", + "logout_confirm_message": "Είστε βέβαιοι ότι θέλετε να αποσυνδεθείτε; Αυτό θα διαγράψει όλα τα δεδομένα της εφαρμογής, τις προσωρινά αποθηκευμένες τιμές και τις ρυθμίσεις.", + "logout_confirm_title": "Επιβεβαίωση Αποσύνδεσης", + "modern_notification_sounds": "Σύγχρονοι Ήχοι Ειδοποιήσεων", + "modern_notification_sounds_description": "Χρησιμοποιήστε το νέο σύγχρονο σετ ήχων για ειδοποιήσεις push. Απενεργοποιήστε το για να χρησιμοποιήσετε τους κλασικούς ήχους.", + "more": "Περισσότερα", + "no_units_available": "Δεν υπάρχουν διαθέσιμες μονάδες", + "no_units_available_help": "Δεν σας έχουν ανατεθεί μονάδες. Ζητήστε από τον διαχειριστή του τμήματός σας να σας προσθέσει στην ομάδα σταθμού μιας μονάδας.", + "units_load_failed": "Δεν ήταν δυνατή η φόρτωση των μονάδων. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά.", + "none_selected": "Καμία Επιλογή", + "notifications": "Ειδοποιήσεις Push", + "notifications_description": "Ενεργοποιήστε τις ειδοποιήσεις για να λαμβάνετε ειδοποιήσεις και ενημερώσεις", + "notifications_enable": "Ενεργοποίηση Ειδοποιήσεων", + "password": "Κωδικός Πρόσβασης", + "polish": "Πολωνικά", + "preferences": "Προτιμήσεις", + "privacy": "Πολιτική Απορρήτου", + "privacy_policy": "Πολιτική Απορρήτου", + "rate": "Αξιολόγηση", + "select_unit": "Επιλογή Μονάδας", + "server": "Διακομιστής", + "server_url": "Διεύθυνση URL Διακομιστή", + "server_url_note": "Σημείωση: Αυτή είναι η διεύθυνση URL του Resgrid API. Χρησιμοποιείται για τη σύνδεση με τον διακομιστή Resgrid. Μην συμπεριλάβετε /api/v4 στη διεύθυνση URL ούτε τελική κάθετο.", + "set_active_unit": "Ορισμός Ενεργής Μονάδας", + "share": "Κοινή χρήση", + "spanish": "Ισπανικά", + "status_page": "Κατάσταση Συστήματος", + "support": "Υποστήριξη", + "support_us": "Υποστηρίξτε Μας", + "swedish": "Σουηδικά", + "terms": "Όροι Χρήσης", + "theme": { + "dark": "Σκούρο", + "light": "Ανοιχτό", + "system": "Συστήματος", + "title": "Θέμα" + }, + "title": "Ρυθμίσεις", + "ukrainian": "Ουκρανικά", + "unit_selected_successfully": "Η μονάδα {{unitName}} επιλέχθηκε με επιτυχία", + "unit_selection": "Επιλογή Μονάδας", + "unit_selection_failed": "Αποτυχία επιλογής μονάδας. Δοκιμάστε ξανά.", + "username": "Όνομα Χρήστη", + "version": "Έκδοση", + "website": "Ιστότοπος" + }, + "status": { + "add_note": "Προσθήκη Σημείωσης", + "both_destinations_enabled": "Μπορεί να ανταποκριθεί σε κλήσεις ή σταθμούς", + "call_destination_enabled": "Μπορεί να ανταποκριθεί σε κλήσεις", + "calls_and_pois_destinations_enabled": "Μπορεί να ανταποκριθεί σε κλήσεις ή σημεία ενδιαφέροντος", + "calls_stations_pois_destinations_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_destinations_enabled": "Μπορεί να ανταποκριθεί σε σταθμούς ή σημεία ενδιαφέροντος", + "stations_tab": "Σταθμοί", + "status_saved_successfully": "Η κατάσταση αποθηκεύτηκε με επιτυχία!" + }, + "tabs": { + "assistant": "Βοηθός", + "calls": "Κλήσεις", + "chat": "Συνομιλία", + "contacts": "Επαφές", + "map": "Χάρτης", + "notes": "Σημειώσεις", + "protocols": "Πρωτόκολλα", + "routes": "Διαδρομές", + "settings": "Ρυθμίσεις", + "weather_alerts": "Καιρός" + }, + "video_feeds": { + "add_feed": "Προσθήκη Ροής", + "added_by": "Προστέθηκε από", + "added_on": "Προστέθηκε στις", + "copy_url": "Αντιγραφή Διεύθυνσης URL", + "delete_confirm_message": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτή τη ροή βίντεο;", + "delete_confirm_title": "Διαγραφή Ροής Βίντεο", + "delete_error": "Αποτυχία διαγραφής ροής βίντεο", + "delete_feed": "Διαγραφή Ροής", + "delete_success": "Η ροή βίντεο διαγράφηκε με επιτυχία", + "description": "Περιγραφή", + "edit_feed": "Επεξεργασία Ροής", + "feed_format": "Μορφή Ροής", + "feed_type": "Τύπος Ροής", + "format_dash": "DASH", + "format_embed": "Ενσωμάτωση", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_other": "Άλλη", + "format_rtsp": "RTSP", + "format_webrtc": "WebRTC", + "format_youtube_live": "YouTube Live", + "latitude": "Γεωγραφικό Πλάτος", + "loading_video": "Φόρτωση βίντεο...", + "longitude": "Γεωγραφικό Μήκος", + "name": "Όνομα", + "no_feeds": "Δεν υπάρχουν διαθέσιμες ροές βίντεο", + "player_title": "Πρόγραμμα Αναπαραγωγής Βίντεο", + "rtsp_not_supported": "Οι ροές RTSP δεν μπορούν να αναπαραχθούν απευθείας. Αντιγράψτε τη διεύθυνση URL για χρήση σε ειδικό πρόγραμμα αναπαραγωγής.", + "save_error": "Αποτυχία αποθήκευσης ροής βίντεο", + "save_success": "Η ροή βίντεο αποθηκεύτηκε με επιτυχία", + "status_active": "Ενεργή", + "status_error": "Σφάλμα", + "status_inactive": "Ανενεργή", + "tab_title": "Βίντεο", + "type_body_cam": "Κάμερα Σώματος", + "type_drone": "Drone", + "type_fixed_camera": "Σταθερή Κάμερα", + "type_other": "Άλλη", + "type_satellite_feed": "Δορυφορική Ροή", + "type_traffic_cam": "Κάμερα Κυκλοφορίας", + "type_weather_cam": "Κάμερα Καιρού", + "type_web_cam": "Διαδικτυακή Κάμερα", + "url": "Διεύθυνση URL", + "url_copied": "Η διεύθυνση URL αντιγράφηκε στο πρόχειρο", + "video_error": "Αποτυχία φόρτωσης βίντεο", + "watch": "Παρακολούθηση", + "webrtc_not_supported": "Η αναπαραγωγή WebRTC έρχεται σύντομα" + }, + "weather_alerts": { + "banner": { + "more_alerts": "+{{count}} ακόμη" + }, + "category": { + "env": "Περιβαλλοντική", + "fire": "Πυρκαγιά", + "health": "Υγεία", + "met": "Μετεωρολογική", + "other": "Άλλη" + }, + "certainty": { + "likely": "Πιθανή", + "observed": "Παρατηρημένη", + "possible": "Ενδεχόμενη", + "unknown": "Άγνωστη", + "unlikely": "Απίθανη" + }, + "detail": { + "area": "Επηρεαζόμενη Περιοχή", + "certainty": "Βεβαιότητα", + "description": "Περιγραφή", + "effective": "Ισχύει Από", + "expires": "Λήγει", + "headline": "Επικεφαλίδα", + "instructions": "Οδηγίες", + "onset": "Έναρξη", + "sender": "Αποστολέας", + "urgency": "Επείγον" + }, + "feature_disabled": "Οι Καιρικές Ειδοποιήσεις Είναι Απενεργοποιημένες", + "feature_disabled_description": "Οι καιρικές ειδοποιήσεις δεν είναι ενεργοποιημένες για το τμήμα σας.", + "filter": { + "all": "Όλες", + "nearby": "Κοντινές" + }, + "loading": "Φόρτωση καιρικών ειδοποιήσεων...", + "no_alerts": "Καμία Καιρική Ειδοποίηση", + "no_alerts_description": "Δεν υπάρχουν ενεργές καιρικές ειδοποιήσεις για την περιοχή σας.", + "search": "Αναζήτηση καιρικών ειδοποιήσεων...", + "severity": { + "extreme": "Ακραία", + "minor": "Ήπια", + "moderate": "Μέτρια", + "severe": "Σοβαρή", + "unknown": "Άγνωστη" + }, + "sort": { + "expires": "Λήγει Σύντομα", + "newest": "Νεότερες", + "severity": "Σοβαρότητα" + }, + "status": { + "active": "Ενεργή", + "cancelled": "Ακυρώθηκε", + "expired": "Έληξε", + "updated": "Ενημερώθηκε" + }, + "title": "Καιρικές Ειδοποιήσεις", + "urgency": { + "expected": "Αναμενόμενο", + "future": "Μελλοντικό", + "immediate": "Άμεσο", + "past": "Παρελθόν", + "unknown": "Άγνωστο" + } + }, + "welcome": "Καλώς ήρθατε στον ιστότοπο της εφαρμογής obytes" +} diff --git a/src/translations/en.json b/src/translations/en.json index 7572f2bb..ff3242e3 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -312,7 +312,8 @@ "what3words_invalid_format": "Invalid what3words format. Please use format: word.word.word", "what3words_not_found": "what3words address not found, please try a different address", "what3words_placeholder": "Enter what3words address (e.g., filled.count.soap)", - "what3words_required": "Please enter a what3words address to search" + "what3words_required": "Please enter a what3words address to search", + "required_fields_missing": "Your department requires these fields before a call can be created: {{fields}}" }, "chat": { "ack_pending_count": "You have {{count}} urgent messages to acknowledge", @@ -974,6 +975,7 @@ "general": "General", "generale": "General", "german": "German", + "greek": "Greek", "github": "Github", "help_center": "Help Center", "italian": "Italian", @@ -990,6 +992,8 @@ "modern_notification_sounds_description": "Use the new modern sound set for push notifications. Turn this off to use the classic sounds.", "more": "More", "no_units_available": "No units available", + "no_units_available_help": "No units are assigned to you. Ask your department administrator to add you to a unit's station group.", + "units_load_failed": "Couldn't load units. Check your connection and try again.", "none_selected": "None Selected", "notifications": "Push Notifications", "notifications_description": "Enable notifications to receive alerts and updates", diff --git a/src/translations/es.json b/src/translations/es.json index 31fb4763..7be2df6b 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -312,7 +312,8 @@ "what3words_invalid_format": "Formato what3words inválido. Use el formato: palabra.palabra.palabra", "what3words_not_found": "Dirección what3words no encontrada, intente con otra dirección", "what3words_placeholder": "Introduce dirección what3words (ej: filled.count.soap)", - "what3words_required": "Por favor introduce una dirección what3words para buscar" + "what3words_required": "Por favor introduce una dirección what3words para buscar", + "required_fields_missing": "Tu departamento exige estos campos antes de poder crear un aviso: {{fields}}" }, "chat": { "ack_pending_count": "Tienes {{count}} mensajes urgentes por confirmar", @@ -974,6 +975,7 @@ "general": "General", "generale": "General", "german": "Alemán", + "greek": "Griego", "github": "Github", "help_center": "Centro de ayuda", "italian": "Italiano", @@ -990,6 +992,8 @@ "modern_notification_sounds_description": "Usa el nuevo conjunto de sonidos modernos para las notificaciones push. Desactívalo para usar los sonidos clásicos.", "more": "Más", "no_units_available": "No hay unidades disponibles", + "no_units_available_help": "No tienes unidades asignadas. Pide al administrador de tu departamento que te añada al grupo de estación de una unidad.", + "units_load_failed": "No se pudieron cargar las unidades. Comprueba tu conexión e inténtalo de nuevo.", "none_selected": "Ninguna seleccionada", "notifications": "Notificaciones push", "notifications_description": "Activa las notificaciones para recibir alertas y actualizaciones", diff --git a/src/translations/fr.json b/src/translations/fr.json index b63fd454..ea9c91a0 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -312,7 +312,8 @@ "what3words_invalid_format": "Format what3words invalide. Veuillez utiliser le format : mot.mot.mot", "what3words_not_found": "Adresse what3words introuvable, veuillez essayer une autre adresse", "what3words_placeholder": "Saisir l'adresse what3words (ex. filled.count.soap)", - "what3words_required": "Veuillez saisir une adresse what3words à rechercher" + "what3words_required": "Veuillez saisir une adresse what3words à rechercher", + "required_fields_missing": "Votre service exige ces champs avant qu'une intervention puisse être créée : {{fields}}" }, "chat": { "ack_pending_count": "Vous avez {{count}} messages urgents à confirmer", @@ -974,6 +975,7 @@ "general": "Général", "generale": "Général", "german": "Allemand", + "greek": "Grec", "github": "Github", "help_center": "Centre d'aide", "italian": "Italien", @@ -990,6 +992,8 @@ "modern_notification_sounds_description": "Utiliser le nouvel ensemble de sons modernes pour les notifications push. Désactivez cette option pour utiliser les sons classiques.", "more": "Plus", "no_units_available": "Aucune unité disponible", + "no_units_available_help": "Aucune unité ne vous est attribuée. Demandez à votre administrateur de service de vous ajouter au groupe de caserne d'une unité.", + "units_load_failed": "Impossible de charger les unités. Vérifiez votre connexion et réessayez.", "none_selected": "Aucune sélectionnée", "notifications": "Notifications push", "notifications_description": "Activez les notifications pour recevoir des alertes et des mises à jour", diff --git a/src/translations/it.json b/src/translations/it.json index 0506f7ac..9124cd4f 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -312,7 +312,8 @@ "what3words_invalid_format": "Formato what3words non valido. Usare il formato: parola.parola.parola", "what3words_not_found": "Indirizzo what3words non trovato, provare un indirizzo diverso", "what3words_placeholder": "Inserisci indirizzo what3words (es. filled.count.soap)", - "what3words_required": "Inserisci un indirizzo what3words da cercare" + "what3words_required": "Inserisci un indirizzo what3words da cercare", + "required_fields_missing": "Il tuo dipartimento richiede questi campi prima di poter creare una chiamata: {{fields}}" }, "chat": { "ack_pending_count": "Hai {{count}} messaggi urgenti da confermare", @@ -974,6 +975,7 @@ "general": "Generale", "generale": "Generale", "german": "Tedesco", + "greek": "Greco", "github": "Github", "help_center": "Centro assistenza", "italian": "Italiano", @@ -990,6 +992,8 @@ "modern_notification_sounds_description": "Usa il nuovo set di suoni moderni per le notifiche push. Disattiva questa opzione per usare i suoni classici.", "more": "Altro", "no_units_available": "Nessuna unità disponibile", + "no_units_available_help": "Non ti è assegnata alcuna unità. Chiedi all'amministratore del dipartimento di aggiungerti al gruppo di stazione di un'unità.", + "units_load_failed": "Impossibile caricare le unità. Controlla la connessione e riprova.", "none_selected": "Nessuno selezionato", "notifications": "Notifiche push", "notifications_description": "Abilita le notifiche per ricevere avvisi e aggiornamenti", diff --git a/src/translations/pl.json b/src/translations/pl.json index 71938226..0c9110db 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -312,7 +312,8 @@ "what3words_invalid_format": "Nieprawidłowy format what3words. Użyj formatu: słowo.słowo.słowo", "what3words_not_found": "Adres what3words nie znaleziony, spróbuj innego adresu", "what3words_placeholder": "Wpisz adres what3words (np. filled.count.soap)", - "what3words_required": "Wpisz adres what3words do wyszukania" + "what3words_required": "Wpisz adres what3words do wyszukania", + "required_fields_missing": "Twoja jednostka wymaga tych pól przed utworzeniem zdarzenia: {{fields}}" }, "chat": { "ack_pending_count": "Masz {{count}} pilnych wiadomości do potwierdzenia", @@ -974,6 +975,7 @@ "general": "Ogólne", "generale": "Ogólne", "german": "Niemiecki", + "greek": "Grecki", "github": "Github", "help_center": "Centrum pomocy", "italian": "Włoski", @@ -990,6 +992,8 @@ "modern_notification_sounds_description": "Używaj nowego, nowoczesnego zestawu dźwięków powiadomień push. Wyłącz, aby używać klasycznych dźwięków.", "more": "Więcej", "no_units_available": "Brak dostępnych jednostek", + "no_units_available_help": "Nie przypisano Ci żadnych jednostek. Poproś administratora jednostki o dodanie Cię do grupy stacji.", + "units_load_failed": "Nie udało się wczytać jednostek. Sprawdź połączenie i spróbuj ponownie.", "none_selected": "Nic nie wybrano", "notifications": "Powiadomienia push", "notifications_description": "Włącz powiadomienia, aby otrzymywać alerty i aktualizacje", diff --git a/src/translations/sv.json b/src/translations/sv.json index 39bd8518..757948c3 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -312,7 +312,8 @@ "what3words_invalid_format": "Ogiltigt what3words-format. Använd formatet: ord.ord.ord", "what3words_not_found": "what3words-adress hittades inte, försök med en annan adress", "what3words_placeholder": "Ange what3words-adress (t.ex. filled.count.soap)", - "what3words_required": "Ange en what3words-adress att söka efter" + "what3words_required": "Ange en what3words-adress att söka efter", + "required_fields_missing": "Din avdelning kräver dessa fält innan ett ärende kan skapas: {{fields}}" }, "chat": { "ack_pending_count": "Du har {{count}} brådskande meddelanden att bekräfta", @@ -974,6 +975,7 @@ "general": "Allmänt", "generale": "Allmänt", "german": "Tyska", + "greek": "Grekiska", "github": "Github", "help_center": "Hjälpcenter", "italian": "Italienska", @@ -990,6 +992,8 @@ "modern_notification_sounds_description": "Använd den nya moderna ljuduppsättningen för push-aviseringar. Stäng av för att använda de klassiska ljuden.", "more": "Mer", "no_units_available": "Inga enheter tillgängliga", + "no_units_available_help": "Inga enheter är tilldelade dig. Be din avdelningsadministratör lägga till dig i en enhets stationsgrupp.", + "units_load_failed": "Det gick inte att läsa in enheter. Kontrollera anslutningen och försök igen.", "none_selected": "Ingen vald", "notifications": "Push-aviseringar", "notifications_description": "Aktivera aviseringar för att ta emot varningar och uppdateringar", diff --git a/src/translations/uk.json b/src/translations/uk.json index 93dd5ee2..a00d5287 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -312,7 +312,8 @@ "what3words_invalid_format": "Неправильний формат what3words. Використовуйте формат: слово.слово.слово", "what3words_not_found": "Адресу what3words не знайдено, спробуйте іншу адресу", "what3words_placeholder": "Введіть адресу what3words (напр. filled.count.soap)", - "what3words_required": "Введіть адресу what3words для пошуку" + "what3words_required": "Введіть адресу what3words для пошуку", + "required_fields_missing": "Ваш відділ вимагає ці поля перед створенням виклику: {{fields}}" }, "chat": { "ack_pending_count": "У вас є {{count}} термінових повідомлень для підтвердження", @@ -974,6 +975,7 @@ "general": "Загальне", "generale": "Загальне", "german": "Німецька", + "greek": "Грецька", "github": "Github", "help_center": "Центр допомоги", "italian": "Італійська", @@ -990,6 +992,8 @@ "modern_notification_sounds_description": "Використовуйте новий сучасний набір звуків для push-сповіщень. Вимкніть, щоб використовувати класичні звуки.", "more": "Більше", "no_units_available": "Немає доступних підрозділів", + "no_units_available_help": "Вам не призначено жодного підрозділу. Попросіть адміністратора вашого відділу додати вас до групи станції підрозділу.", + "units_load_failed": "Не вдалося завантажити підрозділи. Перевірте з'єднання та спробуйте ще раз.", "none_selected": "Нічого не вибрано", "notifications": "Push-сповіщення", "notifications_description": "Увімкніть сповіщення для отримання попереджень та оновлень", From eb812832b093756f7ebba18bc51c753ef383e96b Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 15 Aug 2026 13:11:54 -0700 Subject: [PATCH 2/3] RG-T131 Minor fixes --- src/app/call/new/index.tsx | 97 +++++++++++++------ .../use-new-call-field-policy.test.ts | 97 +++++++++++++++++++ src/hooks/use-new-call-field-policy.ts | 6 ++ src/lib/__tests__/map-center.test.ts | 93 ++++++++++++++++++ src/lib/map-center.ts | 17 +++- src/stores/auth/store.tsx | 14 ++- src/translations/ar.json | 3 +- src/translations/de.json | 3 +- src/translations/el.json | 3 +- src/translations/en.json | 3 +- src/translations/es.json | 3 +- src/translations/fr.json | 3 +- src/translations/it.json | 3 +- src/translations/pl.json | 3 +- src/translations/sv.json | 3 +- src/translations/uk.json | 3 +- 16 files changed, 307 insertions(+), 47 deletions(-) create mode 100644 src/hooks/__tests__/use-new-call-field-policy.test.ts create mode 100644 src/lib/__tests__/map-center.test.ts diff --git a/src/app/call/new/index.tsx b/src/app/call/new/index.tsx index cda972fa..66aa036e 100644 --- a/src/app/call/new/index.tsx +++ b/src/app/call/new/index.tsx @@ -34,7 +34,23 @@ import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; import { type DispatchSelection } from '@/stores/dispatch/store'; import { useNewCallFieldPolicy } from '@/hooks/use-new-call-field-policy'; -import { NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData'; +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.DispatchList]: 'calls.dispatch_to', +}; // Define the form schema using zod const formSchema = z.object({ @@ -190,27 +206,44 @@ export default function NewCall() { const onSubmit = async (data: FormValues) => { try { + // 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) { + 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. - // Form shapes differ slightly between the apps (not every one offers scheduling or a - // destination POI), so the optional fields are read through a loose view. - const policyValues = data as Record; + // DispatchOn is deliberately absent: this app has no scheduling input, so validating it here + // could only produce a required field the dispatcher has no way to fill. The server still + // enforces it and rejects the save with a reason. const missingFields = fieldPolicy.missingRequired({ - [NewCallFieldKeys.Address]: policyValues.address, - [NewCallFieldKeys.Geolocation]: data.latitude && data.longitude ? `${data.latitude},${data.longitude}` : '', - [NewCallFieldKeys.What3Words]: policyValues.what3words, - [NewCallFieldKeys.PlusCode]: policyValues.plusCode, - [NewCallFieldKeys.Note]: policyValues.note, - [NewCallFieldKeys.ContactName]: policyValues.contactName, - [NewCallFieldKeys.ContactInfo]: policyValues.contactInfo, - [NewCallFieldKeys.DestinationPoi]: policyValues.destinationPoiId, - [NewCallFieldKeys.DispatchOn]: policyValues.scheduledOn, + [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.DispatchList]: dispatchSelection.everyone || dispatchSelection.units.length > 0 || dispatchSelection.users.length > 0 || dispatchSelection.groups.length > 0 || dispatchSelection.roles.length > 0, }); if (missingFields.length > 0) { - toast.error(t('calls.required_fields_missing', { fields: missingFields.join(', ') })); + 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; } @@ -902,22 +935,24 @@ export default function NewCall() {
) : null} - - - - {t('calls.contact_info')} - - ( - - - - )} - /> - - + {fieldPolicy.isVisible(NewCallFieldKeys.ContactInfo) ? ( + + + + {t('calls.contact_info')} + + ( + + + + )} + /> + + + ) : null} {t('calls.dispatch_to')} @@ -930,7 +965,7 @@ export default function NewCall() { - 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 index 868a733d..77ae38c3 100644 --- a/src/hooks/use-new-call-field-policy.ts +++ b/src/hooks/use-new-call-field-policy.ts @@ -46,6 +46,12 @@ const hasValue = (value: unknown): boolean => { 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; } 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/map-center.ts b/src/lib/map-center.ts index 90b2f059..a462fe3e 100644 --- a/src/lib/map-center.ts +++ b/src/lib/map-center.ts @@ -29,19 +29,28 @@ export const FALLBACK_MAP_CENTER: MapCenter = { zoomLevel: 9, }; -const isUsableCoordinate = (value: number | null | undefined): value is number => typeof value === 'number' && Number.isFinite(value) && value !== 0; +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. - if (!isUsableCoordinate(latitude) || !isUsableCoordinate(longitude)) { + // 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, - zoomLevel: typeof zoomLevel === 'number' && zoomLevel > 0 ? zoomLevel : FALLBACK_MAP_CENTER.zoomLevel, + // 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, }; }; diff --git a/src/stores/auth/store.tsx b/src/stores/auth/store.tsx index 4283680f..f19ed47a 100644 --- a/src/stores/auth/store.tsx +++ b/src/stores/auth/store.tsx @@ -409,15 +409,25 @@ useAuthStore.subscribe((state, previousState) => { // 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) { - // Cache hygiene must never be able to break sign-in or sign-out. The scope is still correct - // in memory for this session, and a stale entry expires on its own. logger.warn({ message: 'Failed to reset the API cache scope on identity change', context: { error }, diff --git a/src/translations/ar.json b/src/translations/ar.json index 7eb6f0d2..cea0e74f 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -313,7 +313,8 @@ "what3words_not_found": "لم يتم العثور على عنوان what3words، جرب عنوان آخر", "what3words_placeholder": "أدخل عنوان what3words (مثال: filled.count.soap)", "what3words_required": "يرجى إدخال عنوان what3words للبحث", - "required_fields_missing": "يطلب قسمك هذه الحقول قبل إنشاء البلاغ: {{fields}}" + "required_fields_missing": "يطلب قسمك هذه الحقول قبل إنشاء البلاغ: {{fields}}", + "field_policy_loading": "لا يزال يتم تحميل إعدادات البلاغات في قسمك، يرجى المحاولة مرة أخرى بعد لحظات" }, "chat": { "ack_pending_count": "لديك {{count}} رسائل عاجلة للإقرار بها", diff --git a/src/translations/de.json b/src/translations/de.json index 06895707..f5df97fc 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -313,7 +313,8 @@ "what3words_not_found": "what3words-Adresse nicht gefunden, bitte eine andere Adresse versuchen", "what3words_placeholder": "what3words-Adresse eingeben (z. B. filled.count.soap)", "what3words_required": "Bitte eine what3words-Adresse zum Suchen eingeben", - "required_fields_missing": "Ihre Abteilung benötigt diese Felder, bevor ein Einsatz angelegt werden kann: {{fields}}" + "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": { "ack_pending_count": "Sie haben {{count}} dringende Nachrichten zu bestätigen", diff --git a/src/translations/el.json b/src/translations/el.json index 30319882..3f4948b5 100644 --- a/src/translations/el.json +++ b/src/translations/el.json @@ -313,7 +313,8 @@ "what3words_not_found": "Η διεύθυνση what3words δεν βρέθηκε, δοκιμάστε διαφορετική διεύθυνση", "what3words_placeholder": "Εισαγάγετε διεύθυνση what3words (π.χ., filled.count.soap)", "what3words_required": "Εισαγάγετε μια διεύθυνση what3words για αναζήτηση", - "required_fields_missing": "Το τμήμα σας απαιτεί αυτά τα πεδία πριν δημιουργηθεί κλήση: {{fields}}" + "required_fields_missing": "Το τμήμα σας απαιτεί αυτά τα πεδία πριν δημιουργηθεί κλήση: {{fields}}", + "field_policy_loading": "Οι ρυθμίσεις κλήσεων του τμήματός σας φορτώνονται ακόμη, δοκιμάστε ξανά σε λίγο" }, "chat": { "ack_pending_count": "Έχετε {{count}} επείγοντα μηνύματα προς επιβεβαίωση", diff --git a/src/translations/en.json b/src/translations/en.json index ff3242e3..b7adf33a 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -313,7 +313,8 @@ "what3words_not_found": "what3words address not found, please try a different address", "what3words_placeholder": "Enter what3words address (e.g., filled.count.soap)", "what3words_required": "Please enter a what3words address to search", - "required_fields_missing": "Your department requires these fields before a call can be created: {{fields}}" + "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": { "ack_pending_count": "You have {{count}} urgent messages to acknowledge", diff --git a/src/translations/es.json b/src/translations/es.json index 7be2df6b..77f61257 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -313,7 +313,8 @@ "what3words_not_found": "Dirección what3words no encontrada, intente con otra dirección", "what3words_placeholder": "Introduce dirección what3words (ej: filled.count.soap)", "what3words_required": "Por favor introduce una dirección what3words para buscar", - "required_fields_missing": "Tu departamento exige estos campos antes de poder crear un aviso: {{fields}}" + "required_fields_missing": "Tu departamento exige estos campos antes de poder crear un aviso: {{fields}}", + "field_policy_loading": "Aún se están cargando los ajustes de avisos de tu departamento, inténtalo de nuevo en un momento" }, "chat": { "ack_pending_count": "Tienes {{count}} mensajes urgentes por confirmar", diff --git a/src/translations/fr.json b/src/translations/fr.json index ea9c91a0..68ab4629 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -313,7 +313,8 @@ "what3words_not_found": "Adresse what3words introuvable, veuillez essayer une autre adresse", "what3words_placeholder": "Saisir l'adresse what3words (ex. filled.count.soap)", "what3words_required": "Veuillez saisir une adresse what3words à rechercher", - "required_fields_missing": "Votre service exige ces champs avant qu'une intervention puisse être créée : {{fields}}" + "required_fields_missing": "Votre département exige ces champs avant qu'un appel puisse être créé : {{fields}}", + "field_policy_loading": "Les paramètres d'appel de votre département sont encore en cours de chargement, veuillez réessayer dans un instant" }, "chat": { "ack_pending_count": "Vous avez {{count}} messages urgents à confirmer", diff --git a/src/translations/it.json b/src/translations/it.json index 9124cd4f..e5e468a0 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -313,7 +313,8 @@ "what3words_not_found": "Indirizzo what3words non trovato, provare un indirizzo diverso", "what3words_placeholder": "Inserisci indirizzo what3words (es. filled.count.soap)", "what3words_required": "Inserisci un indirizzo what3words da cercare", - "required_fields_missing": "Il tuo dipartimento richiede questi campi prima di poter creare una chiamata: {{fields}}" + "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": { "ack_pending_count": "Hai {{count}} messaggi urgenti da confermare", diff --git a/src/translations/pl.json b/src/translations/pl.json index 0c9110db..6feb2788 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -313,7 +313,8 @@ "what3words_not_found": "Adres what3words nie znaleziony, spróbuj innego adresu", "what3words_placeholder": "Wpisz adres what3words (np. filled.count.soap)", "what3words_required": "Wpisz adres what3words do wyszukania", - "required_fields_missing": "Twoja jednostka wymaga tych pól przed utworzeniem zdarzenia: {{fields}}" + "required_fields_missing": "Twoja jednostka wymaga tych pól przed utworzeniem zgłoszenia: {{fields}}", + "field_policy_loading": "Ustawienia zgłoszeń Twojej jednostki są jeszcze wczytywane, spróbuj ponownie za chwilę" }, "chat": { "ack_pending_count": "Masz {{count}} pilnych wiadomości do potwierdzenia", diff --git a/src/translations/sv.json b/src/translations/sv.json index 757948c3..4615d853 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -313,7 +313,8 @@ "what3words_not_found": "what3words-adress hittades inte, försök med en annan adress", "what3words_placeholder": "Ange what3words-adress (t.ex. filled.count.soap)", "what3words_required": "Ange en what3words-adress att söka efter", - "required_fields_missing": "Din avdelning kräver dessa fält innan ett ärende kan skapas: {{fields}}" + "required_fields_missing": "Din avdelning kräver dessa fält innan ett samtal kan skapas: {{fields}}", + "field_policy_loading": "Din avdelnings samtalsinställningar laddas fortfarande, försök igen om ett ögonblick" }, "chat": { "ack_pending_count": "Du har {{count}} brådskande meddelanden att bekräfta", diff --git a/src/translations/uk.json b/src/translations/uk.json index a00d5287..bac8317d 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -313,7 +313,8 @@ "what3words_not_found": "Адресу what3words не знайдено, спробуйте іншу адресу", "what3words_placeholder": "Введіть адресу what3words (напр. filled.count.soap)", "what3words_required": "Введіть адресу what3words для пошуку", - "required_fields_missing": "Ваш відділ вимагає ці поля перед створенням виклику: {{fields}}" + "required_fields_missing": "Ваш відділ вимагає ці поля перед створенням виклику: {{fields}}", + "field_policy_loading": "Налаштування викликів вашого відділу ще завантажуються, спробуйте ще раз за мить" }, "chat": { "ack_pending_count": "У вас є {{count}} термінових повідомлень для підтвердження", From c1ed54a51f6b1be4ce8222e66346fa82f7d855f3 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 15 Aug 2026 15:47:08 -0700 Subject: [PATCH 3/3] RG-T131 PR#269 fixes --- package.json | 2 +- .../__tests__/cached-client-scope.test.ts | 135 ++++++++ .../common/__tests__/cached-client.test.ts | 75 +++++ src/api/common/cached-client.ts | 16 + src/app/(app)/index.tsx | 2 +- src/app/call/new/index.tsx | 316 ++++++++++-------- .../settings/unit-selection-bottom-sheet.tsx | 6 +- src/hooks/use-new-call-field-policy.ts | 2 +- src/lib/cache/cache-manager.ts | 19 +- .../__tests__/app-reset.service.test.ts | 2 + src/services/app-reset.service.ts | 3 + src/stores/auth/store.tsx | 4 +- .../__tests__/store-cache-scope.test.ts | 124 +++++++ src/stores/security/store.ts | 47 +++ src/stores/units/__tests__/store.test.ts | 105 ++++++ src/stores/units/store.ts | 18 + src/translations/ar.json | 14 +- src/translations/de.json | 14 +- src/translations/el.json | 14 +- src/translations/en.json | 14 +- src/translations/es.json | 14 +- src/translations/fr.json | 14 +- src/translations/it.json | 14 +- src/translations/pl.json | 14 +- src/translations/sv.json | 14 +- src/translations/uk.json | 14 +- 26 files changed, 794 insertions(+), 222 deletions(-) create mode 100644 src/api/common/__tests__/cached-client-scope.test.ts create mode 100644 src/api/common/__tests__/cached-client.test.ts create mode 100644 src/stores/security/__tests__/store-cache-scope.test.ts create mode 100644 src/stores/units/__tests__/store.test.ts diff --git a/package.json b/package.json index 3956b6f7..42682a7b 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "test:ci": "yarn run test --coverage", "test:watch": "yarn run test --watch", "install-maestro": "curl -Ls 'https://get.maestro.mobile.dev' | bash", - "e2e-test": "maestro test .maestro/ -e APP_ID=com.obytes.development", + "e2e-test": "maestro test .maestro/ -e APP_ID=com.resgrid.unit.development", "web:build": "cross-env EXPO_NO_DOTENV=1 expo export --platform web", "web:staging": "cross-env APP_ENV=staging yarn run web", "web:production": "cross-env APP_ENV=production yarn run web", diff --git a/src/api/common/__tests__/cached-client-scope.test.ts b/src/api/common/__tests__/cached-client-scope.test.ts new file mode 100644 index 00000000..e4394e15 --- /dev/null +++ b/src/api/common/__tests__/cached-client-scope.test.ts @@ -0,0 +1,135 @@ +import { type AxiosResponse } from 'axios'; + +const mockGet = jest.fn(); +const mockStorageSet = jest.fn(); +const mockStorageGetString = jest.fn(); +const mockStorageDelete = jest.fn(); +const mockGetBaseApiUrl = jest.fn(); +const mockGetCacheScopeKey = jest.fn(); + +jest.mock('@/api/common/client', () => ({ + createApiEndpoint: jest.fn(() => ({ + get: mockGet, + post: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + })), +})); + +jest.mock('@/lib/storage', () => ({ + storage: { + set: mockStorageSet, + getString: mockStorageGetString, + delete: mockStorageDelete, + getAllKeys: jest.fn(() => []), + }, +})); + +jest.mock('@/lib/storage/app', () => ({ + getBaseApiUrl: mockGetBaseApiUrl, +})); + +jest.mock('@/lib/cache/cache-scope', () => ({ + getCacheScopeKey: mockGetCacheScopeKey, +})); + +type CachedEndpoint = { + get: (params?: Record, options?: { forceRefresh?: boolean }) => Promise>; +}; + +let createCachedApiEndpoint: (endpoint: string, cacheConfig?: { ttl?: number; enabled?: boolean }) => CachedEndpoint; + +const ENDPOINT = '/Units/GetAllUnits'; +const USER_ONE_KEY = `api_cache_https://api.test_7_user-1_${ENDPOINT}`; +const USER_TWO_KEY = `api_cache_https://api.test_9_user-2_${ENDPOINT}`; + +/** Resolves the pending api.get by hand, so the scope can move while the request is in flight. */ +const pendingResponse = () => { + let settle: (value: { data: T }) => void = () => undefined; + mockGet.mockReturnValue( + new Promise<{ data: T }>((resolve) => { + settle = resolve; + }) + ); + + return { settle: (data: T) => settle({ data }) }; +}; + +describe('createCachedApiEndpoint cache scope changes mid-request', () => { + beforeAll(() => { + // Required lazily so the mock factories above see initialized mock functions. + createCachedApiEndpoint = require('@/api/common/cached-client').createCachedApiEndpoint; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockStorageGetString.mockReturnValue(undefined); + mockGetBaseApiUrl.mockReturnValue('https://api.test'); + mockGetCacheScopeKey.mockReturnValue('7_user-1'); + }); + + it('does not store a response under the new identity when the signed-in user changes while it is pending', async () => { + const rows = [{ UnitId: '1' }]; + const request = pendingResponse(); + + const api = createCachedApiEndpoint(ENDPOINT); + const pending = api.get(); + + // The first user signs out and a second signs in before the answer lands. + mockGetCacheScopeKey.mockReturnValue('9_user-2'); + request.settle(rows); + + const response = await pending; + + // The caller that asked still gets its answer... + expect(response.data).toEqual(rows); + // ...but nothing is written to the second user's namespace, nor to the first user's. + expect(mockStorageSet).not.toHaveBeenCalled(); + expect(mockStorageDelete).not.toHaveBeenCalledWith(USER_TWO_KEY); + }); + + it('does not evict the new identity entry when an empty response lands after a scope change', async () => { + const request = pendingResponse(); + + const api = createCachedApiEndpoint(ENDPOINT); + const pending = api.get(); + + mockGetCacheScopeKey.mockReturnValue('9_user-2'); + request.settle([]); + + await pending; + + expect(mockStorageDelete).not.toHaveBeenCalledWith(USER_TWO_KEY); + expect(mockStorageSet).not.toHaveBeenCalled(); + }); + + it('does not store a response under the new server when the base URL changes while it is pending', async () => { + const rows = [{ UnitId: '1' }]; + const request = pendingResponse(); + + const api = createCachedApiEndpoint(ENDPOINT); + const pending = api.get(); + + mockGetBaseApiUrl.mockReturnValue('https://other.test'); + request.settle(rows); + + await pending; + + expect(mockStorageSet).not.toHaveBeenCalled(); + }); + + it('stores the response under the requesting identity when the scope holds still', async () => { + const rows = [{ UnitId: '1' }]; + const request = pendingResponse(); + + const api = createCachedApiEndpoint(ENDPOINT, { enabled: true, ttl: 1000 }); + const pending = api.get(); + + request.settle(rows); + await pending; + + expect(mockStorageSet).toHaveBeenCalledTimes(1); + expect(mockStorageSet.mock.calls[0][0]).toBe(USER_ONE_KEY); + expect(JSON.parse(mockStorageSet.mock.calls[0][1] as string)).toMatchObject({ data: rows, expiresIn: 1000 }); + }); +}); diff --git a/src/api/common/__tests__/cached-client.test.ts b/src/api/common/__tests__/cached-client.test.ts new file mode 100644 index 00000000..9c7ab966 --- /dev/null +++ b/src/api/common/__tests__/cached-client.test.ts @@ -0,0 +1,75 @@ +import { type AxiosResponse } from 'axios'; + +const mockGet = jest.fn(); +const mockCacheGet = jest.fn(); +const mockCacheSet = jest.fn(); +const mockCacheRemove = jest.fn(); +const mockCacheScopeIdentity = jest.fn(() => 'https://api.test_7_user-1'); + +jest.mock('@/api/common/client', () => ({ + createApiEndpoint: jest.fn(() => ({ + get: mockGet, + post: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + })), +})); + +jest.mock('@/lib/cache/cache-manager', () => ({ + cacheManager: { + get: mockCacheGet, + set: mockCacheSet, + remove: mockCacheRemove, + getScopeIdentity: mockCacheScopeIdentity, + }, +})); + +type CachedEndpoint = { + get: (params?: Record, options?: { forceRefresh?: boolean }) => Promise>; +}; + +let createCachedApiEndpoint: (endpoint: string, cacheConfig?: { ttl?: number; enabled?: boolean }) => CachedEndpoint; + +describe('createCachedApiEndpoint empty payload handling', () => { + beforeAll(() => { + // Required lazily so the mock factories above see initialized mock functions. + createCachedApiEndpoint = require('@/api/common/cached-client').createCachedApiEndpoint; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockCacheGet.mockReturnValue(null); + }); + + it('does not cache a response whose body is a direct empty array', async () => { + mockGet.mockResolvedValue({ data: [] }); + + const api = createCachedApiEndpoint('/Units/GetAllUnits'); + const response = await api.get(); + + expect(response.data).toEqual([]); + expect(mockCacheSet).not.toHaveBeenCalled(); + expect(mockCacheRemove).toHaveBeenCalledWith('/Units/GetAllUnits', undefined); + }); + + it('evicts a previously cached non-empty answer when a direct empty array comes back', async () => { + mockGet.mockResolvedValue({ data: [] }); + + const api = createCachedApiEndpoint('/Units/GetAllUnits'); + await api.get({ departmentId: '7' }, { forceRefresh: true }); + + expect(mockCacheSet).not.toHaveBeenCalled(); + expect(mockCacheRemove).toHaveBeenCalledWith('/Units/GetAllUnits', { departmentId: '7' }); + }); + + it('caches a response whose body is a direct non-empty array', async () => { + const rows = [{ UnitId: '1' }]; + mockGet.mockResolvedValue({ data: rows }); + + const api = createCachedApiEndpoint('/Units/GetAllUnits', { enabled: true, ttl: 1000 }); + await api.get(); + + expect(mockCacheRemove).not.toHaveBeenCalled(); + expect(mockCacheSet).toHaveBeenCalledWith('/Units/GetAllUnits', rows, undefined, 1000); + }); +}); diff --git a/src/api/common/cached-client.ts b/src/api/common/cached-client.ts index 71216d79..04125d90 100644 --- a/src/api/common/cached-client.ts +++ b/src/api/common/cached-client.ts @@ -32,6 +32,10 @@ const isEmptyPayload = (payload: unknown): boolean => { return false; } + if (Array.isArray(payload)) { + return payload.length === 0; + } + const body = payload as { Data?: unknown; Status?: unknown }; if (typeof body.Status === 'string' && body.Status.toLowerCase() === 'not_found') { @@ -72,8 +76,20 @@ export const createCachedApiEndpoint = (endpoint: string, cacheConfig: CacheConf } } + // Cache keys are namespaced by server URL and signed-in identity, and both can change while + // this request is in flight — a sign-out, a department switch, a server-URL change. Capture + // the namespace up front: the write below recomputes it, so a response fetched as one + // identity would otherwise be filed under, or evict an entry belonging to, the next one. + const scopeAtRequest = cacheManager.getScopeIdentity(); + const response = await api.get(params); + if (cacheManager.getScopeIdentity() !== scopeAtRequest) { + // Hand the answer back to the caller that asked for it, but leave the new identity's cache + // untouched — this data is not theirs. + return response; + } + 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. diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index 9745c5de..6b9d0dd6 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -20,6 +20,7 @@ import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates'; import { useWeatherAlertBanner } from '@/hooks/use-weather-alert-banner'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; +import { getDepartmentMapCenter } from '@/lib/map-center'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { locationService } from '@/services/location'; import { useCoreStore } from '@/stores/app/core-store'; @@ -28,7 +29,6 @@ import { useMapsStore } from '@/stores/maps/store'; import { useRoutesStore } from '@/stores/routes/store'; import { useToastStore } from '@/stores/toast/store'; import { useWeatherAlertsStore } from '@/stores/weather-alerts/store'; -import { getDepartmentMapCenter } from '@/lib/map-center'; Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); diff --git a/src/app/call/new/index.tsx b/src/app/call/new/index.tsx index 66aa036e..2fad05ed 100644 --- a/src/app/call/new/index.tsx +++ b/src/app/call/new/index.tsx @@ -29,12 +29,12 @@ import { Select, SelectBackdrop, SelectContent, SelectIcon, SelectInput, SelectI import { Text } from '@/components/ui/text'; import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { useAnalytics } from '@/hooks/use-analytics'; +import { useNewCallFieldPolicy } from '@/hooks/use-new-call-field-policy'; import { useToast } from '@/hooks/use-toast'; +import { type NewCallFieldKey, NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; import { type DispatchSelection } from '@/stores/dispatch/store'; -import { useNewCallFieldPolicy } from '@/hooks/use-new-call-field-policy'; -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 @@ -52,6 +52,14 @@ const NEW_CALL_FIELD_LABEL_KEYS: Partial> = { [NewCallFieldKeys.DispatchList]: 'calls.dispatch_to', }; +// The policy's key set is shared with the web app, which offers fields this screen does not have: +// external/incident/reference ids, protocols, linked call, indoor location and a scheduled dispatch +// time. A rule marking one of those required must not be enforced here — there is no input to +// satisfy it, so every submit would fail with a field the dispatcher cannot fill. The server +// enforces the full policy on save and rejects with a reason. The supported set is the set this +// screen renders, which is exactly the set it can label. +const SUPPORTED_NEW_CALL_FIELDS = new Set(Object.keys(NEW_CALL_FIELD_LABEL_KEYS) as NewCallFieldKey[]); + // Define the form schema using zod const formSchema = z.object({ name: z.string().min(1, { message: 'Name is required' }), @@ -220,21 +228,21 @@ export default function NewCall() { // 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 is deliberately absent: this app has no scheduling input, so validating it here - // could only produce a required field the dispatcher has no way to fill. The server still - // enforces it and rejects the save with a reason. - 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.DispatchList]: - dispatchSelection.everyone || dispatchSelection.units.length > 0 || dispatchSelection.users.length > 0 || dispatchSelection.groups.length > 0 || dispatchSelection.roles.length > 0, - }); + // Only the fields this screen renders are enforced — see SUPPORTED_NEW_CALL_FIELDS. + 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.DispatchList]: + dispatchSelection.everyone || dispatchSelection.units.length > 0 || dispatchSelection.users.length > 0 || dispatchSelection.groups.length > 0 || dispatchSelection.roles.length > 0, + }) + .filter((key) => SUPPORTED_NEW_CALL_FIELDS.has(key)); if (missingFields.length > 0) { const missingLabels = missingFields.map((key) => { @@ -637,6 +645,16 @@ export default function NewCall() { } }; + // The location card holds four separate policy fields plus the destination selector; each is + // hidden on its own, and the card itself goes away once the department has turned all of them off. + 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 showDispatchList = fieldPolicy.isVisible(NewCallFieldKeys.DispatchList); + const showLocationCard = showAddress || showGeolocation || showWhat3Words || showPlusCode || showDestinationPoi; + if (isLoading) { return ; } @@ -795,126 +813,140 @@ export default function NewCall() { ) : null} - - {t('calls.call_location')} - - {/* Address Field */} - - - {t('calls.address')} - - ( - - - - - - - - - )} - /> - - - {/* GPS Coordinates Field */} - - - {t('calls.coordinates')} - - ( - - - - - - - - - )} - /> - - - {/* what3words Field */} - - - {t('calls.what3words')} - - ( - - - - - - - - - )} - /> - - - {/* Plus Code Field */} - - - {t('calls.plus_code')} - - ( - - - - - - - + + )} + /> + + ) : 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 geolocation gets picked, so it follows that field. */} + {showGeolocation ? ( + + {selectedLocation ? ( + + ) : ( + - - )} - /> - - - {/* Map Preview */} - - {selectedLocation ? ( - - ) : ( - - )} - - - ( - onChange(poiId != null ? poiId.toString() : '')} + )} + + ) : null} + + {showDestinationPoi ? ( + ( + onChange(poiId != null ? poiId.toString() : '')} + /> + )} /> - )} - /> - + ) : null} + + ) : null} {fieldPolicy.isVisible(NewCallFieldKeys.ContactName) ? ( @@ -954,12 +986,14 @@ export default function NewCall() { ) : null} - - {t('calls.dispatch_to')} - - + {showDispatchList ? ( + + {t('calls.dispatch_to')} + + + ) : null}