diff --git a/__mocks__/expo-av.ts b/__mocks__/expo-av.ts deleted file mode 100644 index 500b5988..00000000 --- a/__mocks__/expo-av.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Mock for expo-av -export const Audio = { - setAudioModeAsync: jest.fn().mockResolvedValue(undefined), - Sound: class MockSound { - static createAsync = jest.fn().mockResolvedValue({ - sound: new this(), - status: { isLoaded: true }, - }); - - playAsync = jest.fn().mockResolvedValue({ status: { isPlaying: true } }); - stopAsync = jest.fn().mockResolvedValue({ status: { isPlaying: false } }); - unloadAsync = jest.fn().mockResolvedValue(undefined); - setVolumeAsync = jest.fn().mockResolvedValue(undefined); - }, - setIsEnabledAsync: jest.fn().mockResolvedValue(undefined), - getPermissionsAsync: jest.fn().mockResolvedValue({ - granted: true, - canAskAgain: true, - expires: 'never', - status: 'granted', - }), - requestPermissionsAsync: jest.fn().mockResolvedValue({ - granted: true, - canAskAgain: true, - expires: 'never', - status: 'granted', - }), -}; - -export const InterruptionModeIOS = { - MixWithOthers: 0, - DoNotMix: 1, - DuckOthers: 2, -}; - -export const AVPlaybackSource = {}; diff --git a/app.config.ts b/app.config.ts index 62503963..f8eeab17 100644 --- a/app.config.ts +++ b/app.config.ts @@ -34,7 +34,6 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ orientation: 'default', icon: './assets/icon.png', userInterfaceStyle: 'automatic', - newArchEnabled: true, updates: { fallbackToCacheTimeout: 0, }, @@ -212,7 +211,10 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ [ '@rnmapbox/maps', { - RNMapboxMapsVersion: '11.8.0', + // Keep in step with the `mapbox` field of the installed @rnmapbox/maps — the JS + // bindings are generated against a specific native SDK, and pinning an older one + // makes style props the bindings emit (symbolZOffset and friends) trap natively. + RNMapboxMapsVersion: '11.23.1', }, ], [ @@ -224,6 +226,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ 'Resgrid Dispatch uses your location, including in the background, to keep the department map updated with your position. For example, while you are working a call away from the console, your location is periodically sent so other dispatchers and responders can see where you are, even when the app is not on screen.', locationAlwaysPermission: 'Resgrid Dispatch uses your location in the background to keep the department map updated with your position. For example, while you are working a call away from the console, your location is periodically sent so other dispatchers and responders can see where you are, even when the app is not on screen.', + // Required even though getMotionActivityAsync() is never called: expo-location links + // CoreMotion (MotionActivityPermissionRequester), and App Store static analysis rejects + // the binary with ITMS-90683 whenever the framework is referenced and the string is absent. + motionUsagePermission: + 'Resgrid Dispatch uses motion data to improve the accuracy of the location shown on the department map. For example, while you are driving to an incident, motion data helps distinguish travel from a stop so other dispatchers and responders see an accurate position.', isIosBackgroundLocationEnabled: true, isAndroidBackgroundLocationEnabled: true, isAndroidForegroundServiceEnabled: true, @@ -257,10 +264,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ android: { extraProguardRules: '-keep class expo.modules.location.** { *; }', extraMavenRepos: ['../../node_modules/@notifee/react-native/android/libs'], - targetSdkVersion: 35, + targetSdkVersion: 36, }, ios: { deploymentTarget: '18.1', + // Apps built with the iOS 27 SDK must adopt the UIKit scene life cycle or they are + // killed at launch. Remove once on SDK 58, whose template adopts it by default. + enableSceneSupport: true, }, }, ], @@ -304,7 +314,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ 'expo-navigation-bar', { position: 'relative', - visibility: 'hidden', + hidden: true, behavior: 'inset-touch', }, ], @@ -328,9 +338,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ], '@config-plugins/react-native-callkeep', 'expo-web-browser', + 'expo-image', + 'expo-sharing', + 'expo-status-bar', './customGradle.plugin.js', './customManifest.plugin.js', - ['app-icon-badge', appIconBadgeConfig], + './plugins/withResourceBundleDeploymentTarget.js', + ['./plugins/withIconBadge.js', appIconBadgeConfig], ], extra: { ...ClientEnv, diff --git a/electron/main.js b/electron/main.js index 7cfec462..9a5d1cb1 100644 --- a/electron/main.js +++ b/electron/main.js @@ -275,6 +275,17 @@ app.whenReady().then(() => { }); }); +// Settings such as the selected server URL live in the renderer's localStorage, which +// Chromium writes to disk lazily. Flush it before exiting so a change made just before +// quitting survives the next launch. +app.on('before-quit', () => { + try { + session.defaultSession.flushStorageData(); + } catch (err) { + console.error('Failed to flush storage data before quit:', err); + } +}); + app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); diff --git a/jest-env-setup.js b/jest-env-setup.js new file mode 100644 index 00000000..2a4c8f5e --- /dev/null +++ b/jest-env-setup.js @@ -0,0 +1,4 @@ +// Must run before jest-expo's preset setup requires `expo/src/winter`. +// Uses React Native's fetch in tests instead of expo's winter fetch, whose +// FetchResponse extends a native class that is unavailable after jest teardown. +process.env.EXPO_PUBLIC_USE_RN_FETCH = '1'; diff --git a/jest-setup.ts b/jest-setup.ts index 6f7a0323..55c56082 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -13,7 +13,8 @@ jest.mock('expo-audio', () => ({ pause: jest.fn(), remove: jest.fn(), replace: jest.fn(), - seekTo: jest.fn(), + seekTo: jest.fn().mockResolvedValue(undefined), + addListener: jest.fn(() => ({ remove: jest.fn() })), playing: false, paused: false, isLoaded: true, diff --git a/jest.config.js b/jest.config.js index 63c1806e..dfe0781b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,8 +1,14 @@ +const expoPreset = require('jest-expo/jest-preset'); + +// Must be set before babel-preset-expo transforms modules (it inlines +// EXPO_PUBLIC_* vars) — makes expo's winter runtime keep RN's fetch in tests. +process.env.EXPO_PUBLIC_USE_RN_FETCH = '1'; + module.exports = { preset: 'jest-expo', // Reanimated v4 worklets: resolve to the non-native builds in Jest - // (vendored — worklets 0.5.1 does not ship jest/resolver.js) - resolver: '/jest.resolver.js', + resolver: 'react-native-worklets/jest/resolver.js', + setupFiles: ['/jest-env-setup.js', ...(expoPreset.setupFiles ?? [])], setupFilesAfterEnv: ['/jest-setup.ts'], testMatch: ['**/?(*.)+(spec|test).ts?(x)'], testPathIgnorePatterns: ['/node_modules/', '\\.\\._.*'], @@ -11,7 +17,7 @@ module.exports = { moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], moduleDirectories: ['node_modules', '/'], transformIgnorePatterns: [ - 'node_modules/(?!((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|@legendapp/motion|@gluestack-ui|nativewind|react-native-css|expo-audio|@aptabase/.*|@shopify/flash-list))', + 'node_modules/(?!((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|standard-navigation|@sentry/react-native|native-base|react-native-svg|@legendapp/motion|@gluestack-ui|nativewind|react-native-css|expo-audio|@aptabase/.*|@dev-plugins/.*|@shopify/flash-list))', ], coverageReporters: ['json-summary', ['text', { file: 'coverage.txt' }], 'cobertura'], reporters: [ diff --git a/jest.resolver.js b/jest.resolver.js deleted file mode 100644 index c889009b..00000000 --- a/jest.resolver.js +++ /dev/null @@ -1,15 +0,0 @@ -// Reanimated v4 worklets: resolve to the non-native builds in Jest. -// Vendored copy of react-native-worklets/jest/resolver.js (added in worklets -// 0.8.x) — this repo pins react-native-worklets 0.5.1, which does not bundle it. - -/** @type {import('jest-resolve').SyncResolver} */ -module.exports = (request, options) => { - const { defaultResolver } = options; - if (options.basedir.includes('react-native-worklets') || request.includes('react-native-worklets')) { - const workletOptions = { ...options }; - workletOptions.extensions = workletOptions.extensions?.filter((ext) => !ext.includes('native')); - options = workletOptions; - } - - return defaultResolver(request, options); -}; diff --git a/package.json b/package.json index a8fd0217..706666b0 100644 --- a/package.json +++ b/package.json @@ -87,30 +87,30 @@ } }, "dependencies": { - "@config-plugins/react-native-callkeep": "^11.0.0", - "@config-plugins/react-native-webrtc": "~12.0.0", - "@dev-plugins/react-query": "~0.2.0", - "@expo/config-plugins": "~54.0.4", + "@config-plugins/react-native-callkeep": "14.0.1", + "@config-plugins/react-native-webrtc": "15.0.1", + "@dev-plugins/react-query": "0.4.0", + "@expo/config-plugins": "~57.0.9", "@expo/html-elements": "~0.10.1", - "@expo/metro-runtime": "~6.1.2", + "@expo/metro-runtime": "~57.0.16", "@gluestack-ui/core": "^5.0.15", "@gluestack-ui/utils": "^5.0.6", "@gorhom/bottom-sheet": "~5.0.5", "@hookform/resolvers": "~3.9.0", "@legendapp/motion": "~2.4.0", - "@livekit/react-native": "^2.9.1", - "@livekit/react-native-expo-plugin": "^1.0.1", - "@livekit/react-native-webrtc": "^137.0.2", + "@livekit/react-native": "2.12.0", + "@livekit/react-native-expo-plugin": "1.0.2", + "@livekit/react-native-webrtc": "144.1.2", "@microsoft/signalr": "~8.0.7", "@notifee/react-native": "^9.1.8", "@novu/react-native": "~2.6.6", "@react-buoy/core": "^1.5.23", "@react-buoy/env": "^1.5.23", "@react-buoy/network": "^1.5.23", - "@react-native-community/netinfo": "^11.4.1", - "@rnmapbox/maps": "10.1.42-rc.0", + "@react-native-community/netinfo": "12.0.1", + "@rnmapbox/maps": "10.3.5", "@semantic-release/git": "^10.0.1", - "@sentry/react-native": "~7.2.0", + "@sentry/react-native": "~8.20.0", "@shopify/flash-list": "2.0.2", "@tanstack/react-query": "~5.52.1", "app-icon-badge": "^0.1.2", @@ -120,73 +120,72 @@ "countly-sdk-react-native-bridge": "^25.4.0", "date-fns": "^4.1.0", "dompurify": "^3.3.1", - "expo": "~54.0.37", - "expo-application": "~7.0.8", - "expo-asset": "~12.0.13", - "expo-audio": "~1.1.1", - "expo-auth-session": "~7.0.11", - "expo-av": "~16.0.8", - "expo-build-properties": "~1.0.10", - "expo-clipboard": "~8.0.8", - "expo-constants": "~18.0.14", - "expo-crypto": "~15.0.9", - "expo-dev-client": "~6.0.21", - "expo-device": "~8.0.10", - "expo-document-picker": "~14.0.8", - "expo-file-system": "~19.0.24", - "expo-font": "~14.0.12", - "expo-image": "~3.0.11", - "expo-image-manipulator": "~14.0.8", - "expo-image-picker": "~17.0.11", - "expo-keep-awake": "~15.0.8", - "expo-linking": "~8.0.12", - "expo-localization": "~17.0.9", - "expo-location": "~19.0.8", - "expo-navigation-bar": "~5.0.10", - "expo-notifications": "~0.32.17", - "expo-router": "~6.0.24", - "expo-screen-orientation": "~9.0.9", - "expo-sharing": "~14.0.8", - "expo-splash-screen": "~31.0.13", - "expo-status-bar": "~3.0.9", - "expo-system-ui": "~6.0.9", - "expo-task-manager": "~14.0.9", - "expo-web-browser": "~15.0.11", + "expo": "~57.0.25", + "expo-application": "~57.0.3", + "expo-asset": "~57.0.18", + "expo-audio": "~57.0.5", + "expo-auth-session": "~57.0.13", + "expo-build-properties": "~57.0.22", + "expo-clipboard": "~57.0.2", + "expo-constants": "~57.0.19", + "expo-crypto": "~57.0.3", + "expo-dev-client": "~57.0.19", + "expo-device": "~57.0.2", + "expo-document-picker": "~57.0.2", + "expo-file-system": "~57.0.7", + "expo-font": "~57.0.4", + "expo-image": "~57.0.5", + "expo-image-manipulator": "~57.0.20", + "expo-image-picker": "~57.0.20", + "expo-keep-awake": "~57.0.2", + "expo-linking": "~57.0.11", + "expo-localization": "~57.0.2", + "expo-location": "~57.0.20", + "expo-navigation-bar": "~57.0.2", + "expo-notifications": "~57.0.21", + "expo-router": "~57.0.23", + "expo-screen-orientation": "~57.0.2", + "expo-sharing": "~57.0.22", + "expo-splash-screen": "~57.0.9", + "expo-status-bar": "~57.0.1", + "expo-system-ui": "~57.0.4", + "expo-task-manager": "~57.0.20", + "expo-web-browser": "~57.0.3", "geojson": "~0.5.0", "he": "^1.2.0", "i18next": "~23.14.0", "jwt-decode": "^4.0.0", - "livekit-client": "^2.15.7", + "livekit-client": "^2.20.1", "lodash": "^4.17.21", "lodash.memoize": "~4.1.2", "lucide-react-native": "~0.475.0", "mapbox-gl": "^3.15.0", "moti": "~0.29.0", "nativewind": "5.0.0-preview.4", - "react": "19.1.0", - "react-dom": "19.1.0", + "react": "19.2.3", + "react-dom": "19.2.3", "react-error-boundary": "~4.0.13", "react-hook-form": "~7.53.0", "react-i18next": "~15.0.1", - "react-native": "0.81.5", + "react-native": "0.86.3", "react-native-base64": "~0.2.1", "react-native-ble-manager": "^12.1.5", "react-native-callkeep": "github:Irfanwani/react-native-callkeep#957193d0716f1c2dfdc18e627cbff0f8a0800971", "react-native-css": "^3.0.7", "react-native-edge-to-edge": "1.6.0", "react-native-flash-message": "~0.4.2", - "react-native-gesture-handler": "~2.28.0", - "react-native-keyboard-controller": "1.18.5", + "react-native-gesture-handler": "~2.32.0", + "react-native-keyboard-controller": "1.21.9", "react-native-logs": "~5.3.0", - "react-native-mmkv": "~3.3.0", - "react-native-reanimated": "~4.1.1", + "react-native-mmkv": "3.3.3", + "react-native-reanimated": "4.5.1", "react-native-restart": "0.0.27", - "react-native-safe-area-context": "~5.6.0", - "react-native-screens": "~4.16.0", - "react-native-svg": "15.12.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.0", + "react-native-svg": "15.15.4", "react-native-web": "^0.21.0", - "react-native-webview": "13.15.0", - "react-native-worklets": "0.5.1", + "react-native-webview": "13.16.1", + "react-native-worklets": "0.10.1", "react-query-kit": "~3.3.0", "sanitize-html": "2.17.0", "tailwind-variants": "~0.2.1", @@ -194,10 +193,11 @@ "zustand": "~4.5.5" }, "devDependencies": { - "@babel/core": "~7.26.0", + "@babel/core": "^7.29.0", "@commitlint/cli": "~19.2.2", "@commitlint/config-conventional": "~19.2.2", - "@expo/config": "~12.0.12", + "@expo/config": "^57.0.0", + "@react-native/jest-preset": "~0.86.3", "@tailwindcss/postcss": "^4.2.0", "@testing-library/jest-dom": "~6.5.0", "@testing-library/react-native": "~12.9.0", @@ -206,7 +206,7 @@ "@types/i18n-js": "~3.8.9", "@types/jest": "~29.5.14", "@types/lodash.memoize": "~4.1.9", - "@types/react": "~19.1.10", + "@types/react": "~19.2.14", "@types/react-native-base64": "~0.2.2", "@types/sanitize-html": "^2.16.0", "@typescript-eslint/eslint-plugin": "~5.62.0", @@ -233,18 +233,17 @@ "eslint-plugin-unused-imports": "~2.0.0", "jest": "~29.7.0", "jest-environment-jsdom": "~29.7.0", - "jest-expo": "~54.0.18", + "jest-expo": "~57.0.5", "jest-junit": "~16.0.0", "lint-staged": "~15.2.9", "np": "~10.0.7", - "patch-package": "^8.0.0", + "patch-package": "^8.0.1", "postinstall-postinstall": "^2.1.0", "prettier": "~3.3.3", "react-native-svg-transformer": "~1.5.1", "tailwindcss": "^4.2.0", - "ts-jest": "~29.1.2", "ts-node": "~10.9.2", - "typescript": "~5.9.2", + "typescript": "~6.0.3", "wait-on": "^9.0.3" }, "repository": { @@ -267,12 +266,7 @@ "install": { "exclude": [ "eslint-config-expo", - "react-native@~0.76.6", - "react-native-reanimated@~3.16.1", - "react-native-gesture-handler@~2.20.0", - "react-native-screens@~4.4.0", - "react-native-safe-area-context@~4.12.0", - "react-native-webview@~13.12.5" + "@sentry/react-native" ] } }, @@ -281,6 +275,7 @@ }, "resolutions": { "form-data": "4.0.4", - "lightningcss": "1.30.1" + "lightningcss": "1.30.1", + "react-native-safe-area-context": "5.7.0" } } diff --git a/patches/@rnmapbox+maps+10.1.42-rc.0.patch b/patches/@rnmapbox+maps+10.3.5.patch similarity index 100% rename from patches/@rnmapbox+maps+10.1.42-rc.0.patch rename to patches/@rnmapbox+maps+10.3.5.patch diff --git a/patches/react-native-webview+13.15.0.patch b/patches/react-native-webview+13.16.1.patch similarity index 100% rename from patches/react-native-webview+13.15.0.patch rename to patches/react-native-webview+13.16.1.patch diff --git a/plugins/__tests__/withIconBadge.test.ts b/plugins/__tests__/withIconBadge.test.ts new file mode 100644 index 00000000..3d274d83 --- /dev/null +++ b/plugins/__tests__/withIconBadge.test.ts @@ -0,0 +1,147 @@ +import type { AppIconBadgeConfig } from 'app-icon-badge/types'; +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import withIconBadge from '../withIconBadge'; + +jest.mock('child_process', () => ({ execFileSync: jest.fn() })); + +interface RenderJob { + icon: string; + dstPath: string; + isAdaptiveIcon: boolean; +} + +interface TestConfig { + _internal: { projectRoot: string }; + icon: string; + ios: { icon: string | Record }; + android: { adaptiveIcon: { foregroundImage: string } }; +} + +const mockExecFileSync = execFileSync as jest.MockedFunction; +const badges: AppIconBadgeConfig['badges'] = [{ text: 'development', type: 'banner', color: 'white' }]; +const outputDir = path.join('.expo', 'app-icon-badge'); + +describe('withIconBadge', () => { + let projectRoot: string; + + const createConfig = (): TestConfig => ({ + _internal: { projectRoot }, + icon: './assets/icon.png', + ios: { icon: './assets/ios-icon.png' }, + android: { adaptiveIcon: { foregroundImage: './assets/adaptive-icon.png' } }, + }); + + const applyPlugin = (config: TestConfig, options: AppIconBadgeConfig): TestConfig => withIconBadge(config, options); + + const getRenderJobs = (call: number): RenderJob[] => JSON.parse((mockExecFileSync.mock.calls[call][1] as string[])[1]); + + beforeEach(() => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'icon-badge-')); + fs.mkdirSync(path.join(projectRoot, 'assets')); + ['icon.png', 'ios-icon.png', 'adaptive-icon.png'].forEach((file) => fs.writeFileSync(path.join(projectRoot, 'assets', file), file)); + + mockExecFileSync.mockReset(); + mockExecFileSync.mockImplementation((_file, args) => { + const jobs: RenderJob[] = JSON.parse((args as string[])[1]); + jobs.forEach((job) => fs.writeFileSync(job.dstPath, 'badged')); + return Buffer.from(''); + }); + }); + + afterEach(() => { + fs.rmSync(projectRoot, { recursive: true, force: true }); + }); + + it('leaves the config untouched when disabled', () => { + const config = applyPlugin(createConfig(), { enabled: false, badges }); + + expect(config.icon).toBe('./assets/icon.png'); + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); + + it('finishes rendering every icon before pointing the config at it', () => { + const config = applyPlugin(createConfig(), { badges }); + const iconPaths = [config.icon, config.ios.icon as string, config.android.adaptiveIcon.foregroundImage]; + + expect(mockExecFileSync).toHaveBeenCalledTimes(1); + expect(getRenderJobs(0).map((job) => job.isAdaptiveIcon)).toEqual([false, false, true]); + expect(new Set(iconPaths).size).toBe(3); + iconPaths.forEach((iconPath) => { + expect(iconPath.startsWith(outputDir)).toBe(true); + expect(fs.readFileSync(path.join(projectRoot, iconPath), 'utf8')).toBe('badged'); + }); + expect(fs.readdirSync(path.join(projectRoot, outputDir)).filter((file) => file.includes('.partial.'))).toEqual([]); + }); + + it('reuses finished icons on repeated config evaluations', () => { + const first = applyPlugin(createConfig(), { badges }); + const second = applyPlugin(createConfig(), { badges }); + + expect(mockExecFileSync).toHaveBeenCalledTimes(1); + expect(second.icon).toBe(first.icon); + expect(second.android.adaptiveIcon.foregroundImage).toBe(first.android.adaptiveIcon.foregroundImage); + }); + + it('does not badge its own output again when applied twice to the same config', () => { + const config = applyPlugin(createConfig(), { badges }); + const badgedPaths = [config.icon, config.ios.icon as string, config.android.adaptiveIcon.foregroundImage]; + + applyPlugin(config, { badges }); + + expect(mockExecFileSync).toHaveBeenCalledTimes(1); + expect([config.icon, config.ios.icon, config.android.adaptiveIcon.foregroundImage]).toEqual(badgedPaths); + badgedPaths.forEach((iconPath) => expect(fs.existsSync(path.join(projectRoot, iconPath))).toBe(true)); + }); + + it('renders an output deleted since the last application from the original icon, not its own output', () => { + const config = applyPlugin(createConfig(), { badges }); + fs.rmSync(path.join(projectRoot, outputDir), { recursive: true, force: true }); + + applyPlugin(config, { badges }); + + expect(mockExecFileSync).toHaveBeenCalledTimes(2); + expect(getRenderJobs(1).map((job) => path.relative(projectRoot, job.icon))).toEqual([path.join('assets', 'icon.png'), path.join('assets', 'ios-icon.png'), path.join('assets', 'adaptive-icon.png')]); + [config.icon, config.ios.icon as string, config.android.adaptiveIcon.foregroundImage].forEach((iconPath) => expect(fs.existsSync(path.join(projectRoot, iconPath))).toBe(true)); + }); + + it('badges the original icon when the same config is applied again with a different badge', () => { + const config = applyPlugin(createConfig(), { badges }); + const firstIcon = config.icon; + + applyPlugin(config, { badges: [{ ...badges[0], text: 'staging' }] }); + + expect(mockExecFileSync).toHaveBeenCalledTimes(2); + expect(path.relative(projectRoot, getRenderJobs(1)[0].icon)).toBe(path.join('assets', 'icon.png')); + expect(config.icon).not.toBe(firstIcon); + }); + + it('re-renders and removes the stale icons when a badge changes', () => { + const first = applyPlugin(createConfig(), { badges }); + const second = applyPlugin(createConfig(), { badges: [{ ...badges[0], text: 'staging' }] }); + + expect(mockExecFileSync).toHaveBeenCalledTimes(2); + expect(second.icon).not.toBe(first.icon); + expect(fs.existsSync(path.join(projectRoot, first.icon))).toBe(false); + expect(fs.existsSync(path.join(projectRoot, second.icon))).toBe(true); + }); + + it('throws rather than pointing at an unfinished icon when rendering fails', () => { + mockExecFileSync.mockImplementation(() => { + throw new Error('render failed'); + }); + + expect(() => applyPlugin(createConfig(), { badges })).toThrow('render failed'); + }); + + it('leaves non-path icon values alone', () => { + const iosIcon = { light: './assets/ios-icon.png' }; + const config = applyPlugin({ ...createConfig(), ios: { icon: iosIcon } }, { badges }); + + expect(config.ios.icon).toBe(iosIcon); + expect(getRenderJobs(0)).toHaveLength(2); + }); +}); diff --git a/plugins/__tests__/withResourceBundleDeploymentTarget.test.ts b/plugins/__tests__/withResourceBundleDeploymentTarget.test.ts new file mode 100644 index 00000000..294b86be --- /dev/null +++ b/plugins/__tests__/withResourceBundleDeploymentTarget.test.ts @@ -0,0 +1,28 @@ +import { addResourceBundleDeploymentTarget } from '../withResourceBundleDeploymentTarget'; + +const podfile = `target 'ResgridDispatch' do + post_install do |installer| + react_native_post_install(installer) + end +end +`; + +describe('addResourceBundleDeploymentTarget', () => { + it('raises resource bundles inside the post_install hook', () => { + const lines = addResourceBundleDeploymentTarget(podfile).split('\n'); + const hookLine = lines.findIndex((line) => line.includes('post_install do |installer|')); + + expect(lines[hookLine + 1]).toContain('@generated begin resgrid-resource-bundle-deployment-target'); + expect(lines.join('\n')).toContain('result.resource_bundle_targets.each'); + expect(lines.join('\n')).toContain("build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = min_ios_version_supported"); + expect(lines.findIndex((line) => line.includes('react_native_post_install'))).toBeGreaterThan(hookLine); + }); + + it('only adds the block once across repeated prebuilds', () => { + const once = addResourceBundleDeploymentTarget(podfile); + const twice = addResourceBundleDeploymentTarget(once); + + expect(twice).toBe(once); + expect(twice.match(/resource_bundle_targets/g)).toHaveLength(1); + }); +}); diff --git a/plugins/withIconBadge.js b/plugins/withIconBadge.js new file mode 100644 index 00000000..46b9200c --- /dev/null +++ b/plugins/withIconBadge.js @@ -0,0 +1,139 @@ +/* eslint-env node */ +const { execFileSync } = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +/** + * Stamps the environment/version badges onto the app icons for non-production builds. + * + * Replaces the `app-icon-badge` config plugin, which starts its icon writes without ever awaiting + * them. Expo registers its own icon mods after project plugins, so they run first and read the + * badged PNGs while a write — or the truncation from one of prebuild's repeated config evaluations — + * is still in flight, and jimp fails with "Could not find MIME for Buffer ". + * + * Config plugins are synchronous, so the badges are rendered in a child process that is waited on. + * Output names carry a hash of their inputs, so repeat evaluations reuse the finished files, and each + * file is renamed into place only once the render has fully completed. + */ + +const OUTPUT_DIR = path.join('.expo', 'app-icon-badge'); +const RENDERER_VERSION = require('app-icon-badge/package.json').version; + +function getIconTargets(config) { + return [ + { + name: 'icon', + source: config.icon, + isAdaptiveIcon: false, + apply: (iconPath) => { + config.icon = iconPath; + }, + }, + { + name: 'ios-icon', + source: config.ios?.icon, + isAdaptiveIcon: false, + apply: (iconPath) => { + config.ios.icon = iconPath; + }, + }, + { + name: 'adaptive-icon', + source: config.android?.adaptiveIcon?.foregroundImage, + isAdaptiveIcon: true, + apply: (iconPath) => { + config.android.adaptiveIcon.foregroundImage = iconPath; + }, + }, + ].filter((target) => typeof target.source === 'string'); +} + +// Per config object, the source each icon was rendered from and the output the config was pointed at. +// Applied to the same config again, the plugin renders from those sources rather than badging its own +// output, so a changed badge or a deleted output is rendered afresh. +const appliedIcons = new WeakMap(); + +function resolveSource(config, target) { + const applied = appliedIcons.get(config)?.[target.name]; + return applied && applied.output === target.source ? applied.source : target.source; +} + +// A copy of a config this plugin already rewrote carries no record of its sources. Its icons are this +// plugin's output; badging them again would stack a second badge and delete the first render. +function isBadgeOutput(projectRoot, source) { + const relative = path.relative(path.resolve(projectRoot, OUTPUT_DIR), path.resolve(projectRoot, source)); + return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative); +} + +function getOutputPath(projectRoot, target, badges) { + const hash = crypto + .createHash('sha256') + .update(fs.readFileSync(path.resolve(projectRoot, target.source))) + .update(JSON.stringify({ badges, isAdaptiveIcon: target.isAdaptiveIcon, renderer: RENDERER_VERSION })) + .digest('hex') + .slice(0, 12); + + return path.join(OUTPUT_DIR, `${target.name}-${hash}.png`); +} + +function renderBadges(projectRoot, targets, badges) { + const outputDir = path.resolve(projectRoot, OUTPUT_DIR); + fs.mkdirSync(outputDir, { recursive: true }); + + const jobs = targets.map((target) => { + const finalPath = path.resolve(projectRoot, target.output); + return { + icon: path.resolve(projectRoot, target.source), + dstPath: finalPath.replace(/\.png$/, '.partial.png'), + finalPath, + badges, + isAdaptiveIcon: target.isAdaptiveIcon, + }; + }); + + // addBadge() does not await its own write, so the files are only complete once the child exits. + execFileSync(process.execPath, [__filename, JSON.stringify(jobs)], { stdio: ['ignore', 'ignore', 'inherit'] }); + + jobs.forEach((job) => fs.renameSync(job.dstPath, job.finalPath)); + + targets.forEach((target) => { + const current = path.basename(target.output); + fs.readdirSync(outputDir) + .filter((file) => file.startsWith(`${target.name}-`) && file !== current) + .forEach((file) => fs.rmSync(path.join(outputDir, file), { force: true })); + }); +} + +const withIconBadge = (config, { enabled = true, badges = [] } = {}) => { + if (!enabled || badges.length === 0) { + return config; + } + + const projectRoot = config._internal?.projectRoot ?? process.cwd(); + const targets = getIconTargets(config) + .map((target) => ({ ...target, source: resolveSource(config, target) })) + .filter((target) => !isBadgeOutput(projectRoot, target.source)) + .map((target) => ({ ...target, output: getOutputPath(projectRoot, target, badges) })); + const pending = targets.filter((target) => !fs.existsSync(path.resolve(projectRoot, target.output))); + + if (pending.length > 0) { + renderBadges(projectRoot, pending, badges); + } + + targets.forEach((target) => target.apply(target.output)); + appliedIcons.set(config, { ...appliedIcons.get(config), ...Object.fromEntries(targets.map((target) => [target.name, { source: target.source, output: target.output }])) }); + return config; +}; + +module.exports = withIconBadge; + +if (require.main === module) { + const { addBadge } = require('app-icon-badge'); + const jobs = JSON.parse(process.argv[2]); + + Promise.all(jobs.map(({ icon, dstPath, badges, isAdaptiveIcon }) => addBadge({ icon, dstPath, badges, isAdaptiveIcon }))).catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/plugins/withResourceBundleDeploymentTarget.js b/plugins/withResourceBundleDeploymentTarget.js new file mode 100644 index 00000000..98f4f967 --- /dev/null +++ b/plugins/withResourceBundleDeploymentTarget.js @@ -0,0 +1,43 @@ +/* eslint-env node */ +const { withPodfile } = require('@expo/config-plugins'); +const { mergeContents } = require('@expo/config-plugins/build/utils/generateCode'); + +/** + * Raises the iOS deployment target of CocoaPods resource-bundle targets to React Native's floor. + * + * `react_native_post_install` lifts every pod's native target to `min_ios_version_supported`, but + * skips the resource bundles that pods such as MapboxMaps, RNSVG and react-native-permissions + * declare. Those keep their podspec minimum (12.4 / 14.0), which Xcode 27 rejects outright — its + * supported range starts at iOS 15.0 — failing the build with "IPHONEOS_DEPLOYMENT_TARGET is set to 12.4". + */ + +const POST_INSTALL_BLOCK = ` installer.target_installation_results.pod_target_installation_results.each_value do |result| + result.resource_bundle_targets.each do |bundle_target| + bundle_target.build_configurations.each do |build_config| + deployment_target = build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + next if deployment_target.nil? || deployment_target.to_f >= min_ios_version_supported.to_f + + build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = min_ios_version_supported + end + end + end`; + +function addResourceBundleDeploymentTarget(podfile) { + return mergeContents({ + tag: 'resgrid-resource-bundle-deployment-target', + src: podfile, + newSrc: POST_INSTALL_BLOCK, + anchor: /^\s*post_install do \|installer\|/, + offset: 1, + comment: '#', + }).contents; +} + +const withResourceBundleDeploymentTarget = (config) => + withPodfile(config, (podfileConfig) => { + podfileConfig.modResults.contents = addResourceBundleDeploymentTarget(podfileConfig.modResults.contents); + return podfileConfig; + }); + +module.exports = withResourceBundleDeploymentTarget; +module.exports.addResourceBundleDeploymentTarget = addResourceBundleDeploymentTarget; diff --git a/src/__tests__/app/call/[id].security.test.tsx b/src/__tests__/app/call/[id].security.test.tsx index 2bbd0be5..231dea3e 100644 --- a/src/__tests__/app/call/[id].security.test.tsx +++ b/src/__tests__/app/call/[id].security.test.tsx @@ -41,6 +41,10 @@ jest.mock('expo-router', () => ({ Stack: { Screen: ({ children, ...props }: any) =>
{children}
, }, + useFocusEffect: jest.fn((callback: () => void) => { + const React = require('react'); + React.useEffect(callback, []); + }), })); // Mock expo-constants @@ -172,13 +176,6 @@ jest.mock('nativewind', () => ({ })); // Mock navigation hook -jest.mock('@react-navigation/native', () => ({ - useFocusEffect: jest.fn((callback: () => void) => { - const React = require('react'); - React.useEffect(callback, []); - }), -})); - // Mock components jest.mock('@/components/common/loading', () => ({ Loading: () =>
Loading...
, diff --git a/src/__tests__/app/call/[id].test.tsx b/src/__tests__/app/call/[id].test.tsx index 871e5528..8656467e 100644 --- a/src/__tests__/app/call/[id].test.tsx +++ b/src/__tests__/app/call/[id].test.tsx @@ -175,6 +175,9 @@ jest.mock('expo-router', () => ({ back: jest.fn(), push: jest.fn(), })), + useIsFocused: jest.fn(() => true), + useNavigation: jest.fn(), + useFocusEffect: jest.fn(), })); // Mock Lucide React Native icons @@ -404,13 +407,6 @@ jest.mock('expo-navigation-bar', () => ({ getBehaviorAsync: jest.fn(), })); -// Mock @react-navigation/native -jest.mock('@react-navigation/native', () => ({ - useIsFocused: jest.fn(() => true), - useNavigation: jest.fn(), - useFocusEffect: jest.fn(), -})); - // Mock react-native-edge-to-edge jest.mock('react-native-edge-to-edge', () => ({ SystemBars: { diff --git a/src/__tests__/app/calls.test.tsx b/src/__tests__/app/calls.test.tsx index d7420e88..c9e57510 100644 --- a/src/__tests__/app/calls.test.tsx +++ b/src/__tests__/app/calls.test.tsx @@ -25,6 +25,11 @@ jest.mock('expo-router', () => ({ router: { push: jest.fn(), }, + useFocusEffect: jest.fn((callback: () => void) => { + const React = require('react'); + React.useEffect(callback, []); + }), + useIsFocused: jest.fn(() => true), })); // Mock storage @@ -205,14 +210,6 @@ jest.mock('@/components/ui/focus-aware-status-bar', () => ({ })); // Mock useFocusEffect and useIsFocused -jest.mock('@react-navigation/native', () => ({ - useFocusEffect: jest.fn((callback: () => void) => { - const React = require('react'); - React.useEffect(callback, []); - }), - useIsFocused: jest.fn(() => true), -})); - import CallsScreen from '../../app/(app)/calls'; describe('CallsScreen', () => { diff --git a/src/__tests__/app/index.test.tsx b/src/__tests__/app/index.test.tsx index f762abb5..4f2793ba 100644 --- a/src/__tests__/app/index.test.tsx +++ b/src/__tests__/app/index.test.tsx @@ -14,7 +14,10 @@ jest.mock('react-native-safe-area-context', () => ({ jest.mock('@/hooks/use-app-lifecycle'); jest.mock('@/stores/app/location-store'); jest.mock('@/hooks/use-map-signalr-updates', () => ({ - useMapSignalRUpdates: jest.fn(), + useMapSignalRUpdates: jest.fn(() => ({ requestRefresh: jest.fn() })), +})); +jest.mock('@/hooks/use-map-live-locations', () => ({ + useMapLiveLocations: jest.fn(() => ({ applyToFetchedPins: (pins: unknown[]) => pins })), })); jest.mock('@/hooks/use-map-layers', () => ({ useMapLayers: jest.fn(() => ({ @@ -30,15 +33,6 @@ jest.mock('@/hooks/use-map-layers', () => ({ })), MapLayerType: { ALL: 'ALL' }, })); -jest.mock('@react-navigation/native', () => ({ - useIsFocused: jest.fn(() => true), - useNavigation: jest.fn(() => ({ - navigate: jest.fn(), - push: jest.fn(), - replace: jest.fn(), - goBack: jest.fn(), - })), -})); jest.mock('@/api/mapping/mapping', () => ({ getMapDataAndMarkers: jest.fn().mockResolvedValue({ Data: { MapMakerInfos: [] }, @@ -71,6 +65,13 @@ jest.mock('expo-router', () => ({ useFocusEffect: jest.fn(() => { // Don't call the callback to prevent infinite loops in tests }), + useIsFocused: jest.fn(() => true), + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + push: jest.fn(), + replace: jest.fn(), + goBack: jest.fn(), + })), })); jest.mock('react-i18next', () => ({ useTranslation: () => ({ diff --git a/src/__tests__/app/map.web.test.tsx b/src/__tests__/app/map.web.test.tsx new file mode 100644 index 00000000..fa90d6e4 --- /dev/null +++ b/src/__tests__/app/map.web.test.tsx @@ -0,0 +1,193 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { getMapDataAndMarkers } from '@/api/mapping/mapping'; +import { LIVE_LOCATION_REFRESH_DELAY_MS } from '@/hooks/use-map-live-locations'; +import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; +import { useSignalRStore } from '@/stores/signalr/signalr-store'; + +import MapWeb from '../../app/(app)/map.web'; + +// Minimal mapbox-gl stand-in that records what the screen does to the map and its markers. +interface MockMarker { + pinId: string; + lngLat: [number, number]; + setLngLat: jest.Mock; + remove: jest.Mock; +} +interface MockMap { + handlers: Record void>; + flyTo: jest.Mock; +} +const mockMarkers: MockMarker[] = []; +const mockMaps: MockMap[] = []; + +jest.mock('mapbox-gl', () => { + class Popup { + setHTML = jest.fn(() => this); + } + class Marker { + pinId: string; + lngLat: [number, number] = [0, 0]; + popup: Popup | undefined; + setLngLat = jest.fn((lngLat: [number, number]) => { + this.lngLat = lngLat; + return this; + }); + remove = jest.fn(); + constructor(options: { element: { pinId: string } }) { + this.pinId = options.element.pinId; + mockMarkers.push(this as unknown as MockMarker); + } + setPopup(popup: Popup) { + this.popup = popup; + return this; + } + getPopup() { + return this.popup; + } + addTo() { + return this; + } + } + class MapboxMap { + handlers: Record void> = {}; + flyTo = jest.fn(); + constructor() { + mockMaps.push(this as unknown as MockMap); + } + addControl() {} + on(event: string, handler: () => void) { + this.handlers[event] = handler; + } + once() {} + off() {} + remove() {} + setStyle() {} + getLayer() { + return undefined; + } + getSource() { + return undefined; + } + addSource() {} + addLayer() {} + } + class Control {} + return { __esModule: true, default: { Map: MapboxMap, Marker, Popup, NavigationControl: Control, GeolocateControl: Control, accessToken: '' } }; +}); + +jest.mock('@/stores/signalr/signalr-store', () => { + const { create } = jest.requireActual('zustand'); + return { useSignalRStore: create(() => ({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 })) }; +}); + +jest.mock('@/api/mapping/mapping', () => ({ getMapDataAndMarkers: jest.fn() })); +jest.mock('@/lib/map-markers-web', () => ({ buildMapPinPopupHtml: jest.fn(() => '
'), createMapMarkerElement: jest.fn((pin: { Id: string }) => ({ pinId: pin.Id })) })); +jest.mock('@/lib/map-center', () => ({ getDepartmentMapCenter: () => ({ latitude: 39, longitude: -119, zoomLevel: 9 }) })); +jest.mock('@/lib/env', () => ({ Env: { MAPBOX_PUBKEY: 'pk.test' } })); +jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() } })); +jest.mock('@/stores/app/location-store', () => ({ useLocationStore: (selector: (state: object) => unknown) => selector({ latitude: null, longitude: null }) })); +jest.mock('nativewind', () => ({ useColorScheme: () => ({ colorScheme: 'light' }) })); +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('expo-router', () => ({ Stack: { Screen: () => null }, useFocusEffect: jest.fn() })); +jest.mock('@/components/ui/focus-aware-status-bar', () => ({ FocusAwareStatusBar: () => null })); +jest.mock('@/hooks/use-active-map-layers', () => ({ useActiveMapLayers: () => ({ activeLayers: [] }) })); +jest.mock('@/hooks/use-analytics', () => ({ useAnalytics: () => ({ trackEvent: jest.fn() }) })); +jest.mock('@/hooks/use-map-layers', () => { + const layerState = { + layers: [], + visibleLayers: new Set(), + isLoading: false, + fetchLayers: () => {}, + toggleLayer: () => {}, + showAllLayers: () => {}, + hideAllLayers: () => {}, + getVisibleLayerData: () => [], + }; + return { useMapLayers: () => layerState, MapLayerType: { ALL: 'ALL' } }; +}); + +const mockGetMapDataAndMarkers = getMapDataAndMarkers as jest.MockedFunction; + +const makePin = (id: string, type: number, latitude: number, longitude: number, poiTypeId: number | null = null) => + ({ Id: id, Type: type, Latitude: latitude, Longitude: longitude, Title: id, PoiTypeId: poiTypeId }) as MapMakerInfoData; + +const HYDRANTS = { PoiTypeId: 7, Name: 'Hydrants', Color: '#f00', ImagePath: '', PoiImage: '', Marker: '', IsDestination: false }; + +const mapResponse = (pins: MapMakerInfoData[]) => ({ Data: { MapMakerInfos: pins, CenterLat: '39.5', CenterLon: '-119.8', ZoomLevel: '11', PoiLayers: [HYDRANTS] } }) as any; + +const REST_PINS = [makePin('c1', 0, 39.1, -119.1), makePin('u12', 1, 39.2, -119.2), makePin('pABC', 3, 39.3, -119.3), makePin('poi5', 4, 39.4, -119.4, 7)]; + +const liveMarker = (pinId: string) => mockMarkers.filter((marker) => marker.pinId === pinId && marker.remove.mock.calls.length === 0); + +describe('Map screen (web) realtime locations', () => { + const originalDocument = (global as any).document; + + beforeEach(() => { + jest.useFakeTimers(); + mockMarkers.length = 0; + mockMaps.length = 0; + mockGetMapDataAndMarkers.mockReset(); + mockGetMapDataAndMarkers.mockResolvedValue(mapResponse(REST_PINS)); + useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 }); + // Only the stylesheet injection touches document; report it as already present. + (global as any).document = { getElementById: () => ({}) }; + }); + + afterEach(() => { + (global as any).document = originalDocument; + jest.useRealTimers(); + }); + + const renderLoadedMap = async () => { + render(, { createNodeMock: () => ({}) }); + act(() => mockMaps[0].handlers.load()); + await waitFor(() => expect(mockMarkers).toHaveLength(REST_PINS.length)); + }; + + const pushUnknownPin = () => { + act(() => { + useSignalRStore.setState({ liveLocations: { u99: { pinId: 'u99', latitude: 38, longitude: -118, timestamp: null, receivedAt: Date.now() } } }); + }); + }; + + it('moves a pushed pin in place and never re-centres the camera', async () => { + await renderLoadedMap(); + expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1); + + act(() => { + useSignalRStore.setState({ liveLocations: { pabc: { pinId: 'pabc', latitude: 40, longitude: -120, timestamp: null, receivedAt: Date.now() } } }); + }); + + const [personMarker] = liveMarker('pABC'); + expect(personMarker.setLngLat).toHaveBeenLastCalledWith([-120, 40]); + expect(mockMarkers).toHaveLength(REST_PINS.length); + mockMarkers.forEach((marker) => expect(marker.remove).not.toHaveBeenCalled()); + + pushUnknownPin(); + await act(async () => { + jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS); + }); + await waitFor(() => expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(2)); + expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1); + }); + + it("keeps the user's POI layer toggles across a background refetch", async () => { + await renderLoadedMap(); + + fireEvent.press(screen.getByTestId('layers-button')); + fireEvent.press(screen.getByText('Hydrants')); + expect(liveMarker('poi5')).toHaveLength(0); + + pushUnknownPin(); + await act(async () => { + jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS); + }); + await waitFor(() => expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(2)); + await act(async () => {}); + + expect(liveMarker('poi5')).toHaveLength(0); + expect(liveMarker('u12')).toHaveLength(1); + }); +}); diff --git a/src/__tests__/app/root/lockscreen.test.tsx b/src/__tests__/app/root/lockscreen.test.tsx index 10859393..9019bd4e 100644 --- a/src/__tests__/app/root/lockscreen.test.tsx +++ b/src/__tests__/app/root/lockscreen.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react-native'; -import { NavigationContainer } from '@react-navigation/native'; import { useRouter } from 'expo-router'; import Lockscreen from '../../../app/lockscreen'; @@ -11,6 +10,7 @@ import useLockscreenStore from '@/stores/lockscreen/store'; // Mock dependencies jest.mock('expo-router', () => ({ useRouter: jest.fn(), + useIsFocused: jest.fn(() => true), })); jest.mock('react-i18next', () => ({ @@ -38,7 +38,7 @@ jest.mock('@/lib/auth/api', () => ({ jest.mock('@/stores/lockscreen/store'); const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { - return {children}; + return <>{children}; }; describe('Lockscreen', () => { @@ -122,7 +122,7 @@ describe('Lockscreen', () => { expect(passwordInput.props.type).toBe('password'); // Find all pressable elements and get the eye icon toggle (it's inside InputSlot) - const allElements = root.findAllByType('View'); + const allElements = root.findAll((node: any) => node.type === 'View'); const inputSlot = allElements.find((el: any) => el.props.className?.includes('pr-3')); // Trigger the press on the InputSlot which has the onPress handler diff --git a/src/__tests__/app/root/maintenance.test.tsx b/src/__tests__/app/root/maintenance.test.tsx index c101589a..f354e81b 100644 --- a/src/__tests__/app/root/maintenance.test.tsx +++ b/src/__tests__/app/root/maintenance.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react-native'; -import { NavigationContainer } from '@react-navigation/native'; import { useRouter } from 'expo-router'; import Maintenance from '../../../app/maintenance'; @@ -9,6 +8,7 @@ import { Env } from '@/lib/env'; // Mock dependencies jest.mock('expo-router', () => ({ useRouter: jest.fn(), + useIsFocused: jest.fn(() => true), })); jest.mock('react-i18next', () => ({ @@ -31,7 +31,7 @@ jest.mock('@/lib/env', () => ({ })); const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { - return {children}; + return <>{children}; }; describe('Maintenance', () => { diff --git a/src/api/config/__tests__/index.test.ts b/src/api/config/__tests__/index.test.ts new file mode 100644 index 00000000..b322545e --- /dev/null +++ b/src/api/config/__tests__/index.test.ts @@ -0,0 +1,38 @@ +const mockAxiosGet = jest.fn(); + +jest.mock('axios', () => ({ + __esModule: true, + default: { get: (...args: unknown[]) => mockAxiosGet(...args) }, +})); +jest.mock('@/lib/storage/app', () => ({ + getBaseApiUrl: () => 'https://api-eu-central.resgrid.com/api/v4', +})); +jest.mock('../../common', () => ({ + createApiEndpoint: () => ({ get: jest.fn() }), +})); + +import { getSystemConfig } from '../index'; + +describe('getSystemConfig', () => { + beforeEach(() => { + mockAxiosGet.mockReset(); + mockAxiosGet.mockResolvedValue({ data: { Data: { Locations: [] } } }); + }); + + it('calls the configured server anonymously so it works before sign-in', async () => { + const result = await getSystemConfig(); + + expect(result).toEqual({ Data: { Locations: [] } }); + expect(mockAxiosGet).toHaveBeenCalledTimes(1); + const [url, config] = mockAxiosGet.mock.calls[0]; + expect(url).toBe('https://api-eu-central.resgrid.com/api/v4/Config/GetSystemConfig'); + expect(config.headers?.Authorization).toBeUndefined(); + expect(config.timeout).toBeGreaterThan(0); + }); + + it('can query an explicit server', async () => { + await getSystemConfig('https://api.resgrid.com/api/v4'); + + expect(mockAxiosGet.mock.calls[0][0]).toBe('https://api.resgrid.com/api/v4/Config/GetSystemConfig'); + }); +}); diff --git a/src/api/config/index.ts b/src/api/config/index.ts index c2180396..f7eec994 100644 --- a/src/api/config/index.ts +++ b/src/api/config/index.ts @@ -1,10 +1,15 @@ +import axios from 'axios'; + +import { getBaseApiUrl } from '@/lib/storage/app'; import { type GetConfigResult } from '@/models/v4/configs/getConfigResult'; import { type GetSystemConfigResult } from '@/models/v4/configs/getSystemConfigResult'; import { createApiEndpoint } from '../common'; +// A custom server URL that doesn't answer shouldn't leave the server picker spinning. +const SYSTEM_CONFIG_TIMEOUT_MS = 10000; + const getConfigApi = createApiEndpoint('/Config/GetConfig'); -const getSystemConfigApi = createApiEndpoint('/Config/GetSystemConfig'); export const getConfig = async (key: string) => { const response = await getConfigApi.get({ @@ -13,7 +18,10 @@ export const getConfig = async (key: string) => { return response.data; }; -export const getSystemConfig = async () => { - const response = await getSystemConfigApi.get(); +// GetSystemConfig is anonymous and is needed on the login screen (to list the Resgrid hosted +// sites) before there is a session, so it bypasses the authenticated api client - that client +// refuses to send any request without an access token. +export const getSystemConfig = async (baseApiUrl: string = getBaseApiUrl()) => { + const response = await axios.get(`${baseApiUrl}/Config/GetSystemConfig`, { timeout: SYSTEM_CONFIG_TIMEOUT_MS }); return response.data; }; diff --git a/src/api/operations/__tests__/operations.test.ts b/src/api/operations/__tests__/operations.test.ts index a60a5434..118c26f1 100644 --- a/src/api/operations/__tests__/operations.test.ts +++ b/src/api/operations/__tests__/operations.test.ts @@ -1,11 +1,26 @@ -jest.mock('@/api/common/client', () => ({ api: { get: jest.fn(), post: jest.fn() } })); +jest.mock('@/api/common/client', () => ({ api: { get: jest.fn(), post: jest.fn(), delete: jest.fn() } })); import { api } from '@/api/common/client'; -import { addResourceUsage, buildF42, getDeployments, getTimeReports, newTimeReport, saveTimeEntries, submitTimeReport, validateCalOesMarsWorkItem } from '@/api/operations/operations'; +import { + addResourceUsage, + approveTimeReport, + buildF42, + deleteExpense, + getDeployments, + getExpenses, + getTimeReports, + newTimeReport, + saveExpense, + saveTimeEntries, + signTimeReport, + submitTimeReport, + validateCalOesMarsWorkItem, +} from '@/api/operations/operations'; beforeEach(() => { jest.clearAllMocks(); jest.mocked(api.get).mockResolvedValue({ data: { Data: [] } }); jest.mocked(api.post).mockResolvedValue({ data: { Data: { Id: 'r-1', Entries: [] }, Errors: [], Warnings: [] } }); + jest.mocked(api.delete).mockResolvedValue({ data: { Status: 'Success' } }); }); it('lists open deployments by default and everything on request', async () => { @@ -17,15 +32,50 @@ it('lists open deployments by default and everything on request', async () => { expect(api.get).toHaveBeenLastCalledWith('/TimeReports/GetTimeReports', { params: { deploymentId: 'dep-1' } }); }); -it('creates, saves and submits a daily time report with the whole entry list as the unit of save', async () => { - await newTimeReport('dep-1', '2026-09-19'); - expect(api.post).toHaveBeenCalledWith('/TimeReports/NewTimeReport', { DeploymentId: 'dep-1', ReportDate: '2026-09-19' }); - const entry = { Id: null, SubjectType: 0, DeploymentPersonnelId: 'p-1', EntryType: 0, StartTime: '2026-09-19T08:00:00', EndTime: '2026-09-19T18:00:00', PaidBreakMinutes: 0, UnpaidBreakMinutes: 30, AgencySuppliedMeals: true, AgencySuppliedAccommodation: false, SortOrder: 0 }; +it('opens crew and individual reports and saves only the local wall clock', async () => { + await newTimeReport('dep-1', '2026-09-19', { deploymentUnitId: 'du-1' }); + expect(api.post).toHaveBeenCalledWith('/TimeReports/NewTimeReport', { DeploymentId: 'dep-1', ReportDate: '2026-09-19', DeploymentUnitId: 'du-1', DeploymentPersonnelId: null }); + await newTimeReport('dep-1', '2026-09-19', { deploymentPersonnelId: 'dp-5' }); + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/NewTimeReport', { DeploymentId: 'dep-1', ReportDate: '2026-09-19', DeploymentUnitId: null, DeploymentPersonnelId: 'dp-5' }); + + const entry = { + Id: 'e-1', + SubjectType: 0, + DeploymentPersonnelId: 'p-1', + EntryType: 0, + StartTime: '2026-09-19T15:00:00.000Z', + EndTime: '2026-09-20T01:00:00.000Z', + StartLocal: '2026-09-19T08:00', + EndLocal: '2026-09-19T18:00', + PaidBreakMinutes: 0, + UnpaidBreakMinutes: 30, + AgencySuppliedMeals: true, + AgencySuppliedAccommodation: false, + SortOrder: 0, + Hours: 9.5, + }; const saved = await saveTimeEntries('r-1', [entry]); - expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SaveTimeEntries', { TimeReportId: 'r-1', Entries: [entry] }); + const { StartTime: _start, EndTime: _end, Hours: _hours, ...sent } = entry; + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SaveTimeEntries', { TimeReportId: 'r-1', Entries: [sent] }); expect(saved.Errors).toEqual([]); await submitTimeReport('r-1'); expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SubmitTimeReport', { Id: 'r-1' }); + await signTimeReport('r-1', true); + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SignTimeReport', { Id: 'r-1', ContractorSigned: true, CustomerSignerName: null }); + await signTimeReport('r-1', false, 'J. Agency'); + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SignTimeReport', { Id: 'r-1', ContractorSigned: false, CustomerSignerName: 'J. Agency' }); + await approveTimeReport('r-1'); + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/ApproveTimeReport', { Id: 'r-1' }); +}); + +it('files, lists and removes expenses with the receipt riding as base64', async () => { + await getExpenses('dep-1'); + expect(api.get).toHaveBeenLastCalledWith('/TimeReports/GetExpenses', { params: { deploymentId: 'dep-1' } }); + const input = { DeploymentId: 'dep-1', ExpenseDate: '2026-09-19', ExpenseType: 4, Amount: 88.4, ReceiptData: 'AAAA', ReceiptFileName: 'r.jpg', ReceiptContentType: 'image/jpeg' }; + await saveExpense(input); + expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SaveExpense', input, { timeout: 60000 }); + await deleteExpense('x-1'); + expect(api.delete).toHaveBeenCalledWith('/TimeReports/DeleteExpense', { params: { id: 'x-1' } }); }); it('posts usage readings and the F-42 draft / validation calls as the server expects them', async () => { diff --git a/src/api/operations/operations.ts b/src/api/operations/operations.ts index 0e1753e7..10352c8e 100644 --- a/src/api/operations/operations.ts +++ b/src/api/operations/operations.ts @@ -6,6 +6,8 @@ import type { CalOesMarsWorkItem, Deployment, DeploymentAccess, + Expense, + ExpenseInput, FieldCostAccess, OperationsResult, ResourceUsage, @@ -22,12 +24,43 @@ export const getDeployments = async (active = true) => (await api.get (await api.get>('/Deployments/GetDeployment', { params: { id } })).data.Data; export const getDeploymentByCallId = async (callId: number) => (await api.get>('/Deployments/GetDeploymentByCallId', { params: { callId } })).data.Data; +/** Who a new report covers: one deployed unit's crew (CTR), one person, or — managers only — the whole deployment. */ +export interface TimeReportScopeInput { + deploymentUnitId?: string | null; + deploymentPersonnelId?: string | null; +} + +// Reports come back with their entries so a day's report opens straight from the list. export const getTimeReports = async (deploymentId: string) => (await api.get>('/TimeReports/GetTimeReports', { params: { deploymentId } })).data.Data; export const getTimeReport = async (id: string) => (await api.get>('/TimeReports/GetTimeReport', { params: { id } })).data.Data; -export const newTimeReport = async (deploymentId: string, reportDate: string) => (await api.post('/TimeReports/NewTimeReport', { DeploymentId: deploymentId, ReportDate: reportDate })).data; -// The whole entry list is the unit of save: the server replaces the report's entries with what it is sent. -export const saveTimeEntries = async (timeReportId: string, entries: TimeEntry[]) => (await api.post('/TimeReports/SaveTimeEntries', { TimeReportId: timeReportId, Entries: entries })).data; +export const newTimeReport = async (deploymentId: string, reportDate: string, scope: TimeReportScopeInput = {}) => + ( + await api.post('/TimeReports/NewTimeReport', { + DeploymentId: deploymentId, + ReportDate: reportDate, + DeploymentUnitId: scope.deploymentUnitId ?? null, + DeploymentPersonnelId: scope.deploymentPersonnelId ?? null, + }) + ).data; +// The server replaces the entries of the subjects the caller may write with what it is sent; everyone else's stay as stored. +// Only the local wall clock travels (StartLocal/EndLocal): the server converts it in the department's zone. +export const saveTimeEntries = async (timeReportId: string, entries: TimeEntry[]) => + ( + await api.post('/TimeReports/SaveTimeEntries', { + TimeReportId: timeReportId, + Entries: entries.map(({ StartTime: _start, EndTime: _end, Hours: _hours, ...entry }) => entry), + }) + ).data; export const submitTimeReport = async (id: string) => (await api.post('/TimeReports/SubmitTimeReport', { Id: id })).data; +/** The crew boss signs as the acting user; the customer (agency) signer is a typed name. */ +export const signTimeReport = async (id: string, contractorSigned: boolean, customerSignerName?: string | null) => + (await api.post('/TimeReports/SignTimeReport', { Id: id, ContractorSigned: contractorSigned, CustomerSignerName: customerSignerName || null })).data; +export const approveTimeReport = async (id: string) => (await api.post('/TimeReports/ApproveTimeReport', { Id: id })).data; + +export const getExpenses = async (deploymentId: string) => (await api.get>('/TimeReports/GetExpenses', { params: { deploymentId } })).data.Data; +// Receipts ride as base64; allow a slower link than the default request timeout. +export const saveExpense = async (input: ExpenseInput) => (await api.post>('/TimeReports/SaveExpense', input, { timeout: 60000 })).data.Data; +export const deleteExpense = async (id: string) => (await api.delete>('/TimeReports/DeleteExpense', { params: { id } })).data; export const getFieldCostAccess = async () => (await api.get>('/FieldCost/GetAccess')).data.Data; export const getResourceUsage = async (deploymentId: string) => (await api.get>('/FieldCost/GetResourceUsage', { params: { deploymentId } })).data.Data; diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 34905fe1..debe8cf8 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -57,6 +57,7 @@ async function teardownSignedInSession(): Promise { ['SignalR update hub', () => signalR.disconnectUpdateHub()], ['SignalR chat hub', () => signalR.disconnectChatHub()], ['SignalR geolocation hub', () => signalR.disconnectGeolocationHub()], + ['realtime map locations', () => signalR.clearLiveLocations()], ['LiveKit room', () => useLiveKitStore.getState().disconnectFromRoom()], ['audio stream', () => useAudioStreamStore.getState().cleanup()], ['audio service', () => audioService.cleanup()], @@ -246,6 +247,24 @@ export default function TabLayout() { if (!isCurrentRun()) return; + // The dispatch console's live maps move unit and personnel pins from the geolocation hub's feed, so it + // connects with the update hub (and, like it, disconnects on background and reconnects on resume). + try { + await useSignalRStore.getState().connectGeolocationHub(); + logger.info({ + message: 'SignalR geolocation hub connected successfully', + context: { platform: Platform.OS }, + }); + } catch (error) { + logger.error({ + message: 'Failed to connect SignalR geolocation hub during initialization', + context: { error, platform: Platform.OS }, + }); + // Don't fail initialization if SignalR connection fails + } + + if (!isCurrentRun()) return; + // Connect the realtime chat hub only when the Chat.System feature flag is on for // this department; when it is off every chat surface stays hidden. if (featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) { diff --git a/src/app/(app)/calls.tsx b/src/app/(app)/calls.tsx index 6a8b891a..46b1066c 100644 --- a/src/app/(app)/calls.tsx +++ b/src/app/(app)/calls.tsx @@ -1,5 +1,4 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { type Href, router } from 'expo-router'; +import { type Href, router, useFocusEffect } from 'expo-router'; import { PlusIcon, RefreshCcwDotIcon, Search, X } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/app/(app)/home.tsx b/src/app/(app)/home.tsx index acc777b1..06c6ddad 100644 --- a/src/app/(app)/home.tsx +++ b/src/app/(app)/home.tsx @@ -1,5 +1,4 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { type Href, router } from 'expo-router'; +import { type Href, router, useFocusEffect } from 'expo-router'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -28,6 +27,7 @@ import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { HStack } from '@/components/ui/hstack'; import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; +import { useSetStatusForCall } from '@/hooks/use-set-status-for-call'; import { logger } from '@/lib/logging'; import { isCallActive, isCallPending, isCallScheduled } from '@/lib/utils'; import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; @@ -462,28 +462,16 @@ export default function DispatchConsole() { }); }; - // Handle setting unit status for a call - const handleSetUnitStatusForCall = (unitId: string, unitName: string) => { - // This could open a status selection modal or navigate to unit status screen - addActivityLogEntry({ - type: 'unit', - action: t('dispatch.unit_status_change'), - description: unitName, - metadata: { unitId, callId: selectedCallId ?? undefined }, - }); - // For now, we'll log the event - in a full implementation, this would open a status picker - }; - - // Handle setting personnel status for a call - const handleSetPersonnelStatusForCall = (personnelId: string, personnelName: string) => { - addActivityLogEntry({ - type: 'personnel', - action: t('dispatch.personnel_status_change'), - description: personnelName, - metadata: { personnelId, callId: selectedCallId ?? undefined }, - }); - // For now, we'll log the event - in a full implementation, this would open a status picker - }; + // "+" set-status-for-call: open the unit/personnel actions panel with the destination preset to the + // selected call; the status is saved through the panel's normal submit path. + const { handleSetUnitStatusForCall, handleSetPersonnelStatusForCall } = useSetStatusForCall({ + calls, + units, + personnel, + selectedCallId, + setSelectedUnitData, + setSelectedPersonnelData, + }); // Get selected call for filter banner const selectedCall = useMemo(() => { diff --git a/src/app/(app)/home.web.tsx b/src/app/(app)/home.web.tsx index d99eae1f..dbf29e57 100644 --- a/src/app/(app)/home.web.tsx +++ b/src/app/(app)/home.web.tsx @@ -1,5 +1,4 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { type Href, router } from 'expo-router'; +import { type Href, router, useFocusEffect } from 'expo-router'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -28,6 +27,7 @@ import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { HStack } from '@/components/ui/hstack'; import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; +import { useSetStatusForCall } from '@/hooks/use-set-status-for-call'; import { logger } from '@/lib/logging'; import { isCallActive, isCallPending, isCallScheduled } from '@/lib/utils'; import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; @@ -547,39 +547,16 @@ export default function DispatchConsoleWeb() { } }, [selectedCallId]); - // Handle setting unit status for call - const handleSetUnitStatusForCall = useCallback( - (unitId: string) => { - const unit = units.find((u) => u.UnitId === unitId); - if (unit) { - addActivityLogEntry({ - type: 'unit', - action: t('dispatch.unit_status_change'), - description: `${unit.Name}`, - metadata: { unitId, callId: selectedCallId ?? undefined }, - }); - // TODO: Implement status change modal or action - } - }, - [units, addActivityLogEntry, t, selectedCallId] - ); - - // Handle setting personnel status for call - const handleSetPersonnelStatusForCall = useCallback( - (personnelId: string) => { - const person = personnel.find((p) => p.UserId === personnelId); - if (person) { - addActivityLogEntry({ - type: 'personnel', - action: t('dispatch.personnel_status_change'), - description: `${person.FirstName} ${person.LastName}`, - metadata: { personnelId, callId: selectedCallId ?? undefined }, - }); - // TODO: Implement status change modal or action - } - }, - [personnel, addActivityLogEntry, t, selectedCallId] - ); + // "+" set-status-for-call: open the unit/personnel actions panel with the destination preset to the + // selected call; the status is saved through the panel's normal submit path. + const { handleSetUnitStatusForCall, handleSetPersonnelStatusForCall } = useSetStatusForCall({ + calls, + units, + personnel, + selectedCallId, + setSelectedUnitData, + setSelectedPersonnelData, + }); const handleWeatherAlertsPress = useCallback(() => { router.push('/(app)/weather-alerts' as Href); diff --git a/src/app/(app)/map.tsx b/src/app/(app)/map.tsx index 528c1f23..15b76fad 100644 --- a/src/app/(app)/map.tsx +++ b/src/app/(app)/map.tsx @@ -16,10 +16,12 @@ import { useActiveMapLayers } from '@/hooks/use-active-map-layers'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAppLifecycle } from '@/hooks/use-app-lifecycle'; import { MapLayerType, useMapLayers } from '@/hooks/use-map-layers'; +import { useMapLiveLocations } from '@/hooks/use-map-live-locations'; import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; -import { createDefaultVisiblePoiLayerIds, filterMapPinsByPoiLayers, getPoiMapLayerId } from '@/lib/poi-map-layers'; +import { getPinEntityId } from '@/lib/map-pin-ids'; +import { createDefaultVisiblePoiLayerIds, filterMapPinsByPoiLayers, getPoiMapLayerId, mergeVisiblePoiLayerIds } from '@/lib/poi-map-layers'; import { onSortOptions } from '@/lib/utils'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { type GetMapLayersData } from '@/models/v4/mapping/getMapLayersResultData'; @@ -124,12 +126,28 @@ export default function Map() { const pulseAnim = useRef(new Animated.Value(1)).current; + // The POI layers last synced from map data; null until the first load. Background refetches (update-hub + // events, realtime locations) must keep the user's layer toggles, so only the first load applies defaults. + const syncedPoiLayersRef = useRef(null); const syncPoiLayers = useCallback((nextPoiLayers: PoiLayerData[]) => { + const previousPoiLayers = syncedPoiLayersRef.current; + syncedPoiLayersRef.current = nextPoiLayers; setPoiLayers(nextPoiLayers); - setVisiblePoiLayerIds(createDefaultVisiblePoiLayerIds(nextPoiLayers)); + setVisiblePoiLayerIds((currentLayerIds) => (previousPoiLayers === null ? createDefaultVisiblePoiLayerIds(nextPoiLayers) : mergeVisiblePoiLayerIds(currentLayerIds, previousPoiLayers, nextPoiLayers))); }, []); - useMapSignalRUpdates(setMapPins, syncPoiLayers); + // Realtime unit/personnel positions from the geolocation hub. Its refetch requests go through the + // update-hub refetch below, which is wired in once that hook has run. + const requestPinsRefreshRef = useRef<(() => void) | null>(null); + const requestPinsRefresh = useCallback(() => requestPinsRefreshRef.current?.(), []); + const { applyToFetchedPins } = useMapLiveLocations({ pins: mapPins, setPins: setMapPins, requestRefresh: requestPinsRefresh }); + + const handleMarkersFetched = useCallback((markers: MapMakerInfoData[], fetchStartedAt: number) => setMapPins(applyToFetchedPins(markers, fetchStartedAt)), [applyToFetchedPins]); + + const { requestRefresh } = useMapSignalRUpdates(handleMarkersFetched, syncPoiLayers); + useEffect(() => { + requestPinsRefreshRef.current = requestRefresh; + }, [requestRefresh]); const togglePoiLayer = useCallback((layerId: string) => { setVisiblePoiLayerIds((currentLayerIds) => { @@ -288,10 +306,11 @@ export default function Map() { const fetchMapDataAndMarkers = async () => { try { + const fetchStartedAt = Date.now(); const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal); if (mapDataAndMarkers && mapDataAndMarkers.Data) { - setMapPins(mapDataAndMarkers.Data.MapMakerInfos); + handleMarkersFetched(mapDataAndMarkers.Data.MapMakerInfos, fetchStartedAt); syncPoiLayers(mapDataAndMarkers.Data.PoiLayers ?? []); } } catch (error) { @@ -316,7 +335,7 @@ export default function Map() { return () => { abortController.abort(); }; - }, [syncPoiLayers]); + }, [handleMarkersFetched, syncPoiLayers]); useEffect(() => { Animated.loop( @@ -384,19 +403,19 @@ export default function Map() { logger.info({ message: 'Setting call as current call', context: { - callId: pin.Id, + callId: getPinEntityId(pin), callTitle: pin.Title, }, }); - await useCoreStore.getState().setActiveCall(pin.Id); + await useCoreStore.getState().setActiveCall(getPinEntityId(pin)); useToastStore.getState().showToast('success', t('map.call_set_as_current')); } catch (error) { logger.error({ message: 'Failed to set call as current call', context: { error, - callId: pin.Id, + callId: getPinEntityId(pin), callTitle: pin.Title, }, }); diff --git a/src/app/(app)/map.web.tsx b/src/app/(app)/map.web.tsx index 41cbad84..78ee2c39 100644 --- a/src/app/(app)/map.web.tsx +++ b/src/app/(app)/map.web.tsx @@ -11,12 +11,13 @@ import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { useActiveMapLayers } from '@/hooks/use-active-map-layers'; import { useAnalytics } from '@/hooks/use-analytics'; import { MapLayerType, useMapLayers } from '@/hooks/use-map-layers'; +import { useMapLiveLocations } from '@/hooks/use-map-live-locations'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; import { getDepartmentMapCenter } from '@/lib/map-center'; -import { getMapPinSummary, hasValidMapCoordinates } from '@/lib/map-markers'; -import { createMapMarkerElement } from '@/lib/map-markers-web'; -import { createDefaultVisiblePoiLayerIds, filterMapPinsByPoiLayers, getPoiMapLayerId } from '@/lib/poi-map-layers'; +import { hasValidMapCoordinates } from '@/lib/map-markers'; +import { buildMapPinPopupHtml, createMapMarkerElement } from '@/lib/map-markers-web'; +import { createDefaultVisiblePoiLayerIds, filterMapPinsByPoiLayers, getPoiMapLayerId, mergeVisiblePoiLayerIds } from '@/lib/poi-map-layers'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { type GetMapLayersData } from '@/models/v4/mapping/getMapLayersResultData'; import { type PoiLayerData } from '@/models/v4/mapping/poiLayerData'; @@ -31,7 +32,9 @@ export default function MapWeb() { const { colorScheme } = useColorScheme(); const mapContainer = useRef(null); const map = useRef(null); - const markersRef = useRef([]); + // Live markers by pin id, so a moved pin is repositioned in place instead of every marker being rebuilt. + const markersRef = useRef>(new Map()); + const markerMetaRef = useRef>(new Map()); const layerIdsRef = useRef([]); const sourceIdsRef = useRef([]); const activeSourceIdsRef = useRef([]); @@ -53,11 +56,21 @@ export default function MapWeb() { // Custom-map region layers (RE1-T105) rendered on top of the legacy vector layers. const { activeLayers } = useActiveMapLayers(); + // The POI layers last synced from map data; null until the first load. A background refetch must keep the + // user's layer toggles, so only the first load applies defaults. + const syncedPoiLayersRef = useRef(null); const syncPoiLayers = useCallback((nextPoiLayers: PoiLayerData[]) => { + const previousPoiLayers = syncedPoiLayersRef.current; + syncedPoiLayersRef.current = nextPoiLayers; setPoiLayers(nextPoiLayers); - setVisiblePoiLayerIds(createDefaultVisiblePoiLayerIds(nextPoiLayers)); + setVisiblePoiLayerIds((currentLayerIds) => (previousPoiLayers === null ? createDefaultVisiblePoiLayerIds(nextPoiLayers) : mergeVisiblePoiLayerIds(currentLayerIds, previousPoiLayers, nextPoiLayers))); }, []); + // Realtime unit/personnel positions from the geolocation hub; its refetch requests reuse fetchMapData below. + const requestPinsRefreshRef = useRef<(() => void) | null>(null); + const requestPinsRefresh = useCallback(() => requestPinsRefreshRef.current?.(), []); + const { applyToFetchedPins } = useMapLiveLocations({ pins: mapPins, setPins: setMapPins, requestRefresh: requestPinsRefresh }); + const togglePoiLayer = useCallback((layerId: string) => { setVisiblePoiLayerIds((currentLayerIds) => { const nextLayerIds = new Set(currentLayerIds); @@ -146,10 +159,14 @@ export default function MapWeb() { }); }); + const markers = markersRef.current; + const markerMeta = markerMetaRef.current; + return () => { // Clean up markers - markersRef.current.forEach((marker) => marker.remove()); - markersRef.current = []; + markers.forEach((marker) => marker.remove()); + markers.clear(); + markerMeta.clear(); map.current?.remove(); map.current = null; @@ -184,17 +201,29 @@ export default function MapWeb() { }, [fetchLayers]) ); - // Fetch map data and markers on mount - useEffect(() => { + // Fetches map data and markers: once on mount, and again in the background when the realtime location feed + // asks for it. Only the first successful load may move the camera; a refresh never touches the user's view. + const fetchAbortRef = useRef(null); + const hasCenteredOnDataRef = useRef(false); + const fetchMapData = useCallback(async () => { + fetchAbortRef.current?.abort(); const abortController = new AbortController(); + fetchAbortRef.current = abortController; - const fetchMapDataAndMarkers = async () => { - try { - const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal); + try { + const fetchStartedAt = Date.now(); + const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal); - if (mapDataAndMarkers && mapDataAndMarkers.Data) { - setMapPins(mapDataAndMarkers.Data.MapMakerInfos); - syncPoiLayers(mapDataAndMarkers.Data.PoiLayers ?? []); + if (abortController.signal.aborted) { + return; + } + + if (mapDataAndMarkers && mapDataAndMarkers.Data) { + setMapPins(applyToFetchedPins(mapDataAndMarkers.Data.MapMakerInfos, fetchStartedAt)); + syncPoiLayers(mapDataAndMarkers.Data.PoiLayers ?? []); + + if (!hasCenteredOnDataRef.current) { + hasCenteredOnDataRef.current = true; // Center map on the data center if provided if (mapDataAndMarkers.Data.CenterLat && mapDataAndMarkers.Data.CenterLon && map.current) { @@ -211,58 +240,94 @@ export default function MapWeb() { } } } - } catch (error) { - // Don't log aborted requests as errors - if (error instanceof Error && (error.name === 'AbortError' || error.message === 'canceled')) { - logger.debug({ - message: 'Map data fetch was aborted during component unmount', - }); - return; - } - - logger.error({ - message: 'Failed to fetch initial map data and markers', - context: { error }, + } + } catch (error) { + // Don't log aborted requests as errors + if (error instanceof Error && (error.name === 'AbortError' || error.message === 'canceled')) { + logger.debug({ + message: 'Map data fetch was aborted', }); + return; + } + + logger.error({ + message: 'Failed to fetch map data and markers', + context: { error }, + }); + } finally { + if (fetchAbortRef.current === abortController) { + fetchAbortRef.current = null; } + } + }, [applyToFetchedPins, syncPoiLayers]); + + useEffect(() => { + requestPinsRefreshRef.current = () => { + void fetchMapData(); }; + }, [fetchMapData]); - fetchMapDataAndMarkers(); + // Fetch map data and markers on mount + useEffect(() => { + void fetchMapData(); + // Abort the request if the component unmounts return () => { - abortController.abort(); + fetchAbortRef.current?.abort(); + fetchAbortRef.current = null; }; - }, [syncPoiLayers]); + }, [fetchMapData]); - // Update markers when mapPins change + // Reconcile markers with the visible pins: create new ones, move moved ones in place, rebuild only those whose + // appearance changed, and remove the rest. The camera is never touched here. useEffect(() => { - if (!map.current || !isMapReady) return; + const instance = map.current; + if (!instance || !isMapReady) return; - // Remove existing markers - markersRef.current.forEach((marker) => marker.remove()); - markersRef.current = []; + const theme = colorScheme === 'dark' ? 'dark' : 'light'; + const seenPinIds = new Set(); - // Add new markers visibleMapPins.forEach((pin) => { if (!hasValidMapCoordinates(pin)) return; + seenPinIds.add(pin.Id); + + const existing = markersRef.current.get(pin.Id); + const meta = markerMetaRef.current.get(pin.Id); + // Pins that did not change keep their object identity, so most markers are skipped outright. + if (existing && meta && meta.pin === pin && meta.signature.startsWith(`${theme}:`)) return; + + const signature = `${theme}:${pin.Title}:${pin.Type}:${pin.PoiTypeId}:${pin.LayerId}:${pin.ImagePath}:${pin.PoiImage}:${pin.Marker}:${pin.Color}:${pin.Address}:${pin.Note}:${pin.PoiTypeName}:${pin.InfoWindowContent}`; + + if (existing && meta && meta.signature === signature) { + if (meta.pin.Latitude !== pin.Latitude || meta.pin.Longitude !== pin.Longitude) { + existing.setLngLat([pin.Longitude, pin.Latitude]); + existing.getPopup()?.setHTML(buildMapPinPopupHtml(pin)); + } + markerMetaRef.current.set(pin.Id, { pin, signature }); + return; + } + + existing?.remove(); // Create custom marker element using shared utility - const el = createMapMarkerElement(pin, colorScheme === 'dark' ? 'dark' : 'light'); + const el = createMapMarkerElement(pin, theme); // Create popup - const popup = new mapboxgl.Popup({ offset: 25 }).setHTML( - `
-

${pin.Title}

- ${getMapPinSummary(pin) ? `

${getMapPinSummary(pin)}

` : ''} -

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

-
` - ); - - const marker = new mapboxgl.Marker({ element: el }).setLngLat([pin.Longitude, pin.Latitude]).setPopup(popup).addTo(map.current!); - - markersRef.current.push(marker); + const popup = new mapboxgl.Popup({ offset: 25 }).setHTML(buildMapPinPopupHtml(pin)); + + const marker = new mapboxgl.Marker({ element: el }).setLngLat([pin.Longitude, pin.Latitude]).setPopup(popup).addTo(instance); + + markersRef.current.set(pin.Id, marker); + markerMetaRef.current.set(pin.Id, { pin, signature }); + }); + + // Remove markers whose pins are gone or hidden + markersRef.current.forEach((marker, pinId) => { + if (!seenPinIds.has(pinId)) { + marker.remove(); + markersRef.current.delete(pinId); + markerMetaRef.current.delete(pinId); + } }); }, [visibleMapPins, isMapReady, colorScheme]); diff --git a/src/app/(app)/operations/[id].tsx b/src/app/(app)/operations/[id].tsx index e072d75b..2cc98d9c 100644 --- a/src/app/(app)/operations/[id].tsx +++ b/src/app/(app)/operations/[id].tsx @@ -4,7 +4,9 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView } from 'react-native'; +import { ExpensesPanel } from '@/components/operations/expenses-panel'; import { MarsPanel } from '@/components/operations/mars-panel'; +import { AwaitingApproval, DayReports, ScopePicker, useScopeLabel } from '@/components/operations/scope-picker'; import { TimeReportEditor } from '@/components/operations/time-report-editor'; import { UsageForm } from '@/components/operations/usage-form'; import { Button, ButtonText } from '@/components/ui/button'; @@ -14,31 +16,46 @@ import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { operationsCapabilities } from '@/lib/operations/capabilities'; -import { localDateKey, rosterSubjects } from '@/lib/operations/time'; -import { DeploymentFinanceMode } from '@/models/v4/operations'; +import { + availableScopes, + canWriteSubject, + coveringReport, + dateOf, + defaultScope, + localDateKey, + reportScope, + reportsOnDate, + scopeKey, + scopeName, + scopeSubjects, + shiftDateKey, + subjectNames, + type TimeScope, +} from '@/lib/operations/time'; +import { DeploymentFinanceMode, TimeReportStatus } from '@/models/v4/operations'; import useAuthStore from '@/stores/auth/store'; import { useDeploymentsStatus } from '@/stores/feature-flags/store'; import { useOperationsStore } from '@/stores/operations/store'; -type Section = 'time' | 'usage' | 'mars'; +type Section = 'time' | 'expenses' | 'usage' | 'mars'; -const shiftDay = (dateKey: string, days: number) => { - const [y, m, d] = dateKey.split('-').map(Number); - return localDateKey(new Date(y, m - 1, d + days)); -}; - -// One deployment: the daily time report for a chosen day, resource usage readings and the MARS F-42 -// state. What the person may edit follows the server's access answer and this app's capabilities. +// One deployment: the crew or individual time report for a chosen day, expenses, resource usage readings and +// the MARS F-42 state. What the person may open and write comes from the server's TimeAccess (their roster row, +// the deployed units they crew) and this app's capabilities; approvers get the submitted queue. export default function OperationsDeploymentScreen() { const { t } = useTranslation(); const router = useRouter(); const { id } = useLocalSearchParams<{ id: string }>(); const flagStatus = useDeploymentsStatus(); const userId = useAuthStore((state) => state.userId); - const { access, costAccess, marsAccess, deployment, report, entries, dirty, issues, warnings, usage, marsItems, validation, busy, error } = useOperationsStore(); + const { costAccess, marsAccess, deployment, reports, scope, report, entries, dirty, issues, warnings, expenses, usage, marsItems, validation, busy, error } = useOperationsStore(); const [dateKey, setDateKey] = useState(localDateKey()); const dateRef = useRef(dateKey); const [section, setSection] = useState
('time'); + const activeUnitId = operationsCapabilities.useActiveUnitId(); + const activeUnitRef = useRef(activeUnitId); + activeUnitRef.current = activeUnitId; + const scopeLabel = useScopeLabel(); useFocusEffect( useCallback(() => { @@ -48,32 +65,57 @@ export default function OperationsDeploymentScreen() { if (!store.access) await store.loadAccess(); if (!useOperationsStore.getState().access?.Enabled) return; await useOperationsStore.getState().open(id); - await useOperationsStore.getState().openReport(dateRef.current, false); - await Promise.all([useOperationsStore.getState().loadUsage(), useOperationsStore.getState().loadMars()]); + const opened = useOperationsStore.getState().deployment; + if (opened) useOperationsStore.getState().setScope(defaultScope(opened, activeUnitRef.current), dateRef.current); + await Promise.all([useOperationsStore.getState().loadExpenses(), useOperationsStore.getState().loadUsage(), useOperationsStore.getState().loadMars()]); })(); return () => useOperationsStore.getState().close(); }, [flagStatus, id]) ); - const activeUnitId = operationsCapabilities.useActiveUnitId(); - const subjects = useMemo(() => (deployment ? rosterSubjects(deployment, { userId: userId ?? null, activeUnitId, manager: !!access?.CanManage }) : []), [deployment, userId, activeUnitId, access?.CanManage]); - const canEditTime = operationsCapabilities.editTime && !!access?.Enabled && subjects.length > 0; + const access = deployment?.TimeAccess ?? null; + const scopes = useMemo(() => (deployment ? availableScopes(deployment) : []), [deployment]); + const names = useMemo(() => (deployment ? subjectNames(deployment) : {}), [deployment]); + // The picker offers the subjects of the open report's own scope (a manager may open any report on the day). + const openScope: TimeScope | null = report ? reportScope(report) : scope; + const subjects = useMemo(() => (deployment ? scopeSubjects(deployment, openScope) : []), [deployment, openScope]); + const dayReports = useMemo(() => reportsOnDate(reports, dateKey), [reports, dateKey]); + const awaiting = useMemo(() => reports.filter((candidate) => candidate.Status === TimeReportStatus.Submitted).sort((a, b) => a.ReportDate.localeCompare(b.ReportDate)), [reports]); + const canApprove = operationsCapabilities.approveTime && !!access?.CanApprove; + const supervises = !!access?.CanManage || canApprove; + const covered = useMemo(() => { + if (report || !scope) return null; + const subject = scope.kind === 'crew' ? scope.unitId : scope.kind === 'individual' ? scope.personnelId : null; + return subject ? coveringReport(reports, dateKey, subject) : null; + }, [report, scope, reports, dateKey]); + const canCreate = operationsCapabilities.editTime && !!scope && (scope.kind !== 'deployment' || !!access?.CanManage); + const writable = useCallback((subjectId: string) => !!deployment && canWriteSubject(deployment, subjectId), [deployment]); const showUsage = operationsCapabilities.recordUsage && !!costAccess?.Enabled && !!costAccess.CanRecordUsage; + const showExpenses = operationsCapabilities.recordExpenses && (access?.CanManage || (access?.WritableSubjectIds.length ?? 0) > 0); const showMars = !!marsAccess?.Enabled && deployment?.FinanceMode === DeploymentFinanceMode.CostRecovery; - const canDraftF42 = operationsCapabilities.draftF42 && showMars && (!!marsAccess?.CanManage || subjects.length > 0); + const canDraftF42 = operationsCapabilities.draftF42 && showMars && (!!marsAccess?.CanManage || (access?.WritableSubjectIds.length ?? 0) > 0); const usageUnits = useMemo(() => { if (!deployment) return []; const units = deployment.Units.filter((unit) => unit.IsActive); - if (access?.CanManage || !activeUnitId) return units; - const mine = units.filter((unit) => String(unit.UnitId) === activeUnitId); - return mine.length > 0 ? mine : units; - }, [deployment, access?.CanManage, activeUnitId]); + if (access?.CanManage) return units; + const crewed = units.filter((unit) => access?.CrewUnitIds.includes(unit.Id)); + const mine = activeUnitId ? crewed.filter((unit) => String(unit.UnitId) === activeUnitId) : []; + return mine.length > 0 ? mine : crewed; + }, [deployment, access, activeUnitId]); const changeDay = (days: number) => { - const next = shiftDay(dateKey, days); + const next = shiftDateKey(dateKey, days); dateRef.current = next; setDateKey(next); - void useOperationsStore.getState().openReport(next, false); + useOperationsStore.getState().setScope(useOperationsStore.getState().scope, next); + }; + + const openReport = (target: { Id: string; ReportDate: string }) => { + const day = dateOf(target.ReportDate); + dateRef.current = day; + setDateKey(day); + setSection('time'); + useOperationsStore.getState().selectReport(target.Id); }; if (flagStatus === 'disabled') return ; @@ -105,13 +147,27 @@ export default function OperationsDeploymentScreen() { {t('operations.roster', { units: deployment.Units.filter((unit) => unit.IsActive).length, personnel: deployment.Personnel.filter((person) => person.IsActive).length })} + {access && access.CrewUnitIds.length > 0 ? ( + + {t('operations.crewOf', { + names: access.CrewUnitIds.map((unitId) => scopeName(deployment, { kind: 'crew', unitId })) + .filter(Boolean) + .join(', '), + })} + + ) : null} ) : null} {deployment ? ( - + + {showExpenses ? ( + + ) : null} {showUsage ? ( ) : null} - + ) : null} - {deployment && (section === 'time' || section === 'usage') ? ( + {deployment && section !== 'mars' ? ( changeDay(-1)} testID="operations-day-previous" accessibilityRole="button" accessibilityLabel={t('operations.previousDay')}> @@ -136,20 +192,51 @@ export default function OperationsDeploymentScreen() { ) : null} {deployment && section === 'time' ? ( - + {canApprove ? : null} + useOperationsStore.getState().setScope(next, dateKey)} /> + {supervises ? : null} + {scopes.length === 0 && !supervises ? {t('operations.time.notOnRoster')} : null} + {scope || report ? ( + void useOperationsStore.getState().openReport(dateKey, true)} + onChange={(next) => useOperationsStore.getState().setEntries(next)} + onSave={() => void useOperationsStore.getState().save()} + onSubmit={() => void useOperationsStore.getState().submit()} + onSign={(crewBoss, customer) => void useOperationsStore.getState().sign(crewBoss, customer)} + onApprove={() => report && void useOperationsStore.getState().approve(report.Id)} + /> + ) : null} + + ) : null} + {deployment && section === 'expenses' && showExpenses ? ( + void useOperationsStore.getState().openReport(dateKey, true)} - onChange={(next) => useOperationsStore.getState().setEntries(next)} - onSave={() => void useOperationsStore.getState().save()} - onSubmit={() => void useOperationsStore.getState().submit()} + onAdd={(input) => useOperationsStore.getState().addExpense(input)} + onRemove={(expenseId) => useOperationsStore.getState().removeExpense(expenseId)} /> ) : null} {deployment && section === 'usage' && showUsage ? ( diff --git a/src/app/(app)/personnel.tsx b/src/app/(app)/personnel.tsx index 0ba7f08a..5b5523dc 100644 --- a/src/app/(app)/personnel.tsx +++ b/src/app/(app)/personnel.tsx @@ -1,5 +1,4 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { type Href, router } from 'expo-router'; +import { type Href, router, useFocusEffect } from 'expo-router'; import { Circle, Search, User, Users, X } from 'lucide-react-native'; import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/app/(app)/pois.tsx b/src/app/(app)/pois.tsx index 2a429570..72223611 100644 --- a/src/app/(app)/pois.tsx +++ b/src/app/(app)/pois.tsx @@ -1,5 +1,4 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { type Href, router } from 'expo-router'; +import { type Href, router, useFocusEffect } from 'expo-router'; import { ChevronDownIcon, MapPinned, Search, X } from 'lucide-react-native'; import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/app/(app)/records.tsx b/src/app/(app)/records.tsx index 33e9575b..c80a919e 100644 --- a/src/app/(app)/records.tsx +++ b/src/app/(app)/records.tsx @@ -91,7 +91,7 @@ export default function RecordsScreen() { // The flag is authoritative and resolves fail-closed; an unknown state waits rather than redirecting. if (flagStatus === 'disabled') { - return ; + return ; } return ( diff --git a/src/app/(app)/scheduled-calls.tsx b/src/app/(app)/scheduled-calls.tsx index 4a92efb7..1b8d5087 100644 --- a/src/app/(app)/scheduled-calls.tsx +++ b/src/app/(app)/scheduled-calls.tsx @@ -1,5 +1,4 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { type Href, router, Stack } from 'expo-router'; +import { type Href, router, Stack, useFocusEffect } from 'expo-router'; import { CalendarClockIcon, Search, X } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index fd3ede32..6ce99531 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -49,6 +49,15 @@ export default function Settings() { await login({ username: data.username, password: data.password }); }; + // The current session's tokens were issued by the previous server, so switching servers + // signs the user out to log in again against the new one. + const handleServerUrlChanged = React.useCallback(async () => { + logger.info({ + message: 'Server URL changed, signing out', + }); + await signOut(); + }, [signOut]); + useEffect(() => { if (status === 'signedIn' && isAuthenticated) { logger.info({ @@ -117,7 +126,7 @@ export default function Settings() { setShowLoginInfo(false)} onSubmit={handleLoginInfoSubmit} /> - setShowServerUrl(false)} /> + setShowServerUrl(false)} onUrlChanged={handleServerUrlChanged} /> ); } diff --git a/src/app/(app)/units.tsx b/src/app/(app)/units.tsx index 58104861..b7948b7f 100644 --- a/src/app/(app)/units.tsx +++ b/src/app/(app)/units.tsx @@ -1,5 +1,4 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { type Href, router } from 'expo-router'; +import { type Href, router, useFocusEffect } from 'expo-router'; import { Circle, MapPin, Search, Truck, X } from 'lucide-react-native'; import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/app/(app)/weather-alerts/index.tsx b/src/app/(app)/weather-alerts/index.tsx index 3679d0e8..e5aa28cd 100644 --- a/src/app/(app)/weather-alerts/index.tsx +++ b/src/app/(app)/weather-alerts/index.tsx @@ -1,5 +1,4 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { type Href, router } from 'expo-router'; +import { type Href, router, useFocusEffect } from 'expo-router'; import { SettingsIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useMemo, useState } from 'react'; diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index d4702556..ab6d2204 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -5,12 +5,11 @@ import '../lib/i18n'; import { Env } from '@env'; import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; import { FloatingDevTools } from '@react-buoy/core'; -import { createNavigationContainerRef, DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import * as Sentry from '@sentry/react-native'; import { isRunningInExpoGo } from 'expo'; import { useFonts } from 'expo-font'; import * as Notifications from 'expo-notifications'; -import { Stack, useNavigationContainerRef } from 'expo-router'; +import { DarkTheme, DefaultTheme, Stack, ThemeProvider, useNavigationContainerRef } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import React, { useEffect } from 'react'; import { LogBox, Platform, useColorScheme } from 'react-native'; @@ -33,7 +32,6 @@ import { uuidv4 } from '@/lib/utils'; import { appInitializationService } from '@/services/app-initialization.service'; export { ErrorBoundary } from 'expo-router'; -export const navigationRef = createNavigationContainerRef(); export const unstable_settings = { initialRouteName: '(app)', diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index 59d5ebc1..89a753b7 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -24,6 +24,7 @@ import { useTranslation } from 'react-i18next'; import { Alert, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; import WebView from 'react-native-webview'; +import { ActivityLinkLegend, ActivityLinkMarker } from '@/components/calls/activity-link-marker'; import { CallSiteInfoTabPanel } from '@/components/calls/call-site-info-tab-panel'; import { VideoFeedsTab } from '@/components/callVideoFeeds/video-feeds-tab'; import { CheckInTab } from '@/components/checkIn/check-in-tab'; @@ -602,9 +603,12 @@ export default function CallDetail() { {callExtraData.Activity.map((event, index) => ( - - {event.StatusText} - + + + {event.StatusText} + + + {event.Name} - {event.Group} @@ -612,6 +616,7 @@ export default function CallDetail() { {event.Note} ))} + ) : ( {t('call_detail.no_timeline')} diff --git a/src/app/call/[id].web.tsx b/src/app/call/[id].web.tsx index 1f96cb9d..dd337145 100644 --- a/src/app/call/[id].web.tsx +++ b/src/app/call/[id].web.tsx @@ -26,6 +26,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; +import { ActivityLinkLegend, ActivityLinkMarker } from '@/components/calls/activity-link-marker'; import { CallSiteInfoTabPanel } from '@/components/calls/call-site-info-tab-panel'; import { VideoFeedsTab } from '@/components/callVideoFeeds/video-feeds-tab'; import { CheckInTab } from '@/components/checkIn/check-in-tab'; @@ -419,7 +420,10 @@ export default function CallDetailWeb() { - {event.StatusText} + + {event.StatusText} + + {event.Name} - {event.Group} @@ -428,6 +432,7 @@ export default function CallDetailWeb() { ))} + ) : ( {t('call_detail.no_timeline')} @@ -1107,10 +1112,16 @@ const styles = StyleSheet.create({ timelineContent: { flex: 1, }, + timelineStatusRow: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: 8, + marginBottom: 2, + }, timelineStatus: { fontSize: 15, fontWeight: '600', - marginBottom: 2, }, timelineInfo: { fontSize: 14, diff --git a/src/app/login/index.web.tsx b/src/app/login/index.web.tsx index 62f6dfea..d2e7eacb 100644 --- a/src/app/login/index.web.tsx +++ b/src/app/login/index.web.tsx @@ -9,12 +9,11 @@ import { Pressable, StyleSheet, useWindowDimensions, View } from 'react-native'; import Animated, { FadeIn, FadeInDown, FadeInRight, FadeInUp, FadeOut, FadeOutLeft } from 'react-native-reanimated'; import * as z from 'zod'; -import { getSystemConfig } from '@/api/config'; import { Text } from '@/components/ui/text'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAuth } from '@/lib/auth'; import { logger } from '@/lib/logging'; -import { buildApiUrl, CUSTOM_SERVER_VALUE, toBaseUrl, URL_PATTERN } from '@/lib/server-url'; +import { buildApiUrl, CUSTOM_SERVER_VALUE, findLocationByUrl, loadServerLocations, toBaseUrl, URL_PATTERN } from '@/lib/server-url'; import { type ResgridSystemLocation } from '@/models/v4/configs/getSystemConfigResultData'; import { useServerUrlStore } from '@/stores/app/server-url-store'; @@ -209,15 +208,8 @@ export default function LoginWeb() { // can match it against a hosted site or show it for editing in the custom field. const currentUrl = await getUrl(); const currentBaseUrl = toBaseUrl(currentUrl); - - let fetchedLocations: ResgridSystemLocation[] = []; - try { - const result = await getSystemConfig(); - fetchedLocations = result?.Data?.Locations ?? []; - } catch (err) { - // Best-effort: on failure the user can still enter a custom URL manually. - logger.error({ message: 'Failed to load Resgrid hosted sites', context: { error: err } }); - } + // Always includes the Resgrid hosted sites, even if the current server can't be reached. + const fetchedLocations = await loadServerLocations(); if (isCancelled) { return; @@ -227,7 +219,7 @@ export default function LoginWeb() { // Preselect the hosted site whose API URL matches the persisted URL; otherwise fall // back to the Custom option and show the persisted URL so the user can edit it. - const matchedLocation = fetchedLocations.find((location) => toBaseUrl(location.ApiUrl) === currentBaseUrl); + const matchedLocation = findLocationByUrl(fetchedLocations, currentUrl); if (matchedLocation) { setSelectedServer(matchedLocation.Name); setServerUrlValue('url', toBaseUrl(matchedLocation.ApiUrl)); @@ -546,7 +538,7 @@ export default function LoginWeb() { > {serverLocations.map((location) => ( ))} diff --git a/src/app/personnel/[id].tsx b/src/app/personnel/[id].tsx index 2039d7cf..e940e0cf 100644 --- a/src/app/personnel/[id].tsx +++ b/src/app/personnel/[id].tsx @@ -1,11 +1,13 @@ import { format } from 'date-fns'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { Building2, Circle, Clock, Mail, MapPin, Phone, Shield, User, Users } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { getPersonnelInfo } from '@/api/personnel/personnel'; +import { UdfFieldsRenderer } from '@/components/calls/udf-fields-renderer'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; import { Box } from '@/components/ui/box'; @@ -19,11 +21,15 @@ import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; +// The detail page only displays custom fields; nothing is edited here. +const ignoreUdfValues = () => {}; + export default function PersonnelDetail() { const { id } = useLocalSearchParams(); const userId = Array.isArray(id) ? id[0] : id; const router = useRouter(); const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); const { trackEvent } = useAnalytics(); const [person, setPerson] = useState(null); @@ -321,14 +327,8 @@ export default function PersonnelDetail() { {t('personnel.custom_fields')} - - {person.UdfValues.map((udf, index) => ( - - {udf.UdfFieldId} - {udf.Value || '—'} - - ))} - + {/* Labels and option values come from the definition; the raw rows only carry field ids and stored keys. */} + ) : null} diff --git a/src/app/records/[id].tsx b/src/app/records/[id].tsx index cdebc466..cb5eb1eb 100644 --- a/src/app/records/[id].tsx +++ b/src/app/records/[id].tsx @@ -42,27 +42,39 @@ export default function RecordScreen() { const [attested, setAttested] = useState(false); const [isBusy, setIsBusy] = useState(false); const [message, setMessage] = useState(null); + // Edits made since the record was last loaded; they exist only here until they are saved. + const [dirty, setDirty] = useState(false); - const load = useCallback(async () => { + /** Loads the record as the server holds it now, replacing any local edits. */ + const fetchRecord = useCallback(async (): Promise => { if (!id) { - return; + return null; } - setIsBusy(true); try { const response = await getRecord(id); const loaded = response?.Data ?? null; setRecord(loaded); setValues(toValueMap(loaded?.Values?.Cells)); + setDirty(false); if (loaded?.DefinitionKey) { setSchema(await fetchSchema(loaded.DefinitionKey, loaded.DefinitionVersion)); } + return loaded; } catch (error) { logger.error({ message: 'Record load failed', context: { error, id } }); setMessage(t('records.load_failed')); + return null; + } + }, [id, fetchSchema, t]); + + const load = useCallback(async () => { + setIsBusy(true); + try { + await fetchRecord(); } finally { setIsBusy(false); } - }, [id, fetchSchema, t]); + }, [fetchRecord]); useEffect(() => { void load(); @@ -72,6 +84,39 @@ export default function RecordScreen() { const editable = record ? record.State === RmsRecordState.Draft || record.State === RmsRecordState.Returned : false; const entry = record?.DefinitionKey ? entryFor(record.DefinitionKey, record.DefinitionVersion) : null; + const handleChange = useCallback((next: ValueMap) => { + setValues(next); + setDirty(true); + }, []); + + /** Sends the edited values and returns the record as the server now holds it, or null when it was not saved. */ + const persist = useCallback( + async (current: RecordData): Promise => { + const clientRecordId = `edit-${current.RecordId}-${current.RowVersion}`; + const draft = { + clientRecordId, + recordId: current.RecordId, + definitionKey: current.DefinitionKey ?? '', + definitionVersion: current.DefinitionVersion, + name: current.RecordNumber ?? current.DraftReference ?? entry?.Name ?? t('records.untitled'), + values: toValueList(values), + rowVersion: current.RowVersion, + updatedOn: new Date().toISOString(), + }; + stageDraft(draft); + // Passed directly as well: a definition that seals values is never staged on the device. + const result = await pushDraft(clientRecordId, draft); + if (!result.ok) { + // A conflict is shown, never resolved by overwriting: the person reloads and decides. + setMessage(result.conflict ? t(`records.conflict_${result.conflict.replace('-', '_')}`) : (result.error ?? t('records.save_failed'))); + return null; + } + discardDraft(clientRecordId); + return fetchRecord(); + }, + [values, entry, stageDraft, pushDraft, discardDraft, fetchRecord, t] + ); + const save = useCallback(async () => { if (!record || !schema) { return; @@ -84,46 +129,42 @@ export default function RecordScreen() { } setIsBusy(true); try { - const clientRecordId = `edit-${record.RecordId}-${record.RowVersion}`; - stageDraft({ - clientRecordId, - recordId: record.RecordId, - definitionKey: record.DefinitionKey ?? '', - definitionVersion: record.DefinitionVersion, - name: record.RecordNumber ?? record.DraftReference ?? entry?.Name ?? t('records.untitled'), - values: toValueList(values), - rowVersion: record.RowVersion, - updatedOn: new Date().toISOString(), - }); - const result = await pushDraft(clientRecordId); - if (result.ok) { - discardDraft(clientRecordId); + if (await persist(record)) { setMessage(t('records.saved')); - await load(); - return; } - // A conflict is shown, never resolved by overwriting: the person reloads and decides. - setMessage(result.conflict ? t(`records.conflict_${result.conflict.replace('-', '_')}`) : (result.error ?? t('records.save_failed'))); } finally { setIsBusy(false); } - }, [record, schema, values, entry, stageDraft, pushDraft, discardDraft, load, t]); + }, [record, schema, values, persist, t]); const submit = useCallback(async () => { if (!record) { return; } + if (dirty && schema) { + const found = validate(schema, values, false); + setIssues(found); + if (found.length > 0) { + setMessage(t('records.fix_fields')); + return; + } + } setIsBusy(true); try { - const result = await submitForReview(record.RecordId, record.RowVersion); + // Unsaved edits go first; otherwise the server's older copy is what would be put up for review. + const current = dirty ? await persist(record) : record; + if (!current) { + return; + } + const result = await submitForReview(current.RecordId, current.RowVersion); setMessage(result.ok ? t('records.submitted') : (result.error ?? t('records.save_failed'))); if (result.ok) { - await load(); + await fetchRecord(); } } finally { setIsBusy(false); } - }, [record, submitForReview, load, t]); + }, [record, schema, values, dirty, persist, submitForReview, fetchRecord, t]); const complete = useCallback(async () => { if (!record || !schema) { @@ -137,15 +178,20 @@ export default function RecordScreen() { } setIsBusy(true); try { - const result = await finalize(record.RecordId, record.RowVersion, attested); + // The values just validated are the ones finalized, so unsaved edits are sent first. + const current = dirty ? await persist(record) : record; + if (!current) { + return; + } + const result = await finalize(current.RecordId, current.RowVersion, attested); setMessage(result.ok ? t('records.finalized') : (result.error ?? t('records.save_failed'))); if (result.ok) { - await load(); + await fetchRecord(); } } finally { setIsBusy(false); } - }, [record, schema, values, attested, finalize, load, t]); + }, [record, schema, values, dirty, attested, persist, finalize, fetchRecord, t]); if (!record) { return ( @@ -176,7 +222,8 @@ export default function RecordScreen() { {message ? {message} : null} - {schema ? 0} forFinalize={false} onChange={setValues} /> : } + {/* Locked while a save is in flight: the reload that follows it replaces the values, so an edit made meanwhile would be lost. */} + {schema ? 0 || isBusy} forFinalize={false} onChange={handleChange} /> : } {/* Attachments upload against a server-owned session, so an interruption resumes. */} diff --git a/src/app/records/__tests__/[id].test.tsx b/src/app/records/__tests__/[id].test.tsx new file mode 100644 index 00000000..ca4bc4d9 --- /dev/null +++ b/src/app/records/__tests__/[id].test.tsx @@ -0,0 +1,212 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { getRecord } from '@/api/records/records'; +import { RmsRecordState } from '@/models/v4/records'; +import { useRecordsStore } from '@/stores/records/store'; + +import RecordScreen from '../[id]'; + +interface MockChildrenProps { + children?: React.ReactNode; + testID?: string; +} + +interface MockButtonProps extends MockChildrenProps { + onPress?: () => void; + isDisabled?: boolean; +} + +interface MockCheckboxProps extends MockChildrenProps { + onChange?: (checked: boolean) => void; +} + +interface MockRecordFormProps { + onChange: (values: Record) => void; + readOnly?: boolean; +} + +jest.mock('expo-router', () => ({ + Stack: { Screen: () => null }, + useLocalSearchParams: () => ({ id: 'r1' }), + useRouter: () => ({ back: jest.fn() }), +})); + +jest.mock('react-i18next', () => { + // Stable like the real one: the screen's loaders depend on it. + const t = (key: string) => key; + return { useTranslation: () => ({ t }) }; +}); + +jest.mock('@/api/records/records', () => ({ + getRecord: jest.fn(), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +jest.mock('@/lib/records/schema', () => ({ + toValueList: jest.fn((values: Record) => Object.values(values)), + toValueMap: jest.fn(() => ({})), + unsupportedFieldKeys: jest.fn(() => []), + validate: jest.fn(() => []), +})); + +jest.mock('@/stores/records/store', () => ({ + useRecordsStore: jest.fn(), +})); + +jest.mock('@/components/records/record-attachments', () => ({ + RecordAttachments: () => null, +})); + +jest.mock('@/components/records/record-form', () => { + const { TouchableOpacity } = require('react-native'); + return { + RecordForm: ({ onChange, readOnly }: MockRecordFormProps) => ( + onChange({ 'main:notes': { SectionKey: 'main', FieldKey: 'notes', Value: 'Edited' } })} /> + ), + }; +}); + +jest.mock('@/components/ui/button', () => { + const { Text, TouchableOpacity } = require('react-native'); + return { + Button: ({ children, onPress, isDisabled, testID }: MockButtonProps) => ( + + {children} + + ), + ButtonText: ({ children }: MockChildrenProps) => {children}, + }; +}); + +jest.mock('@/components/ui/checkbox', () => { + const { TouchableOpacity, View } = require('react-native'); + return { + Checkbox: ({ children, onChange, testID }: MockCheckboxProps) => ( + onChange?.(true)} testID={testID}> + {children} + + ), + CheckboxIcon: () => null, + CheckboxIndicator: ({ children }: MockChildrenProps) => {children}, + CheckboxLabel: () => null, + }; +}); + +jest.mock('@/components/ui/badge', () => { + const { Text, View } = require('react-native'); + return { + Badge: ({ children }: MockChildrenProps) => {children}, + BadgeText: ({ children }: MockChildrenProps) => {children}, + }; +}); + +jest.mock('@/components/ui/box', () => { + const { View } = require('react-native'); + return { Box: ({ children }: MockChildrenProps) => {children} }; +}); + +jest.mock('@/components/ui/vstack', () => { + const { View } = require('react-native'); + return { VStack: ({ children }: MockChildrenProps) => {children} }; +}); + +jest.mock('@/components/ui/heading', () => { + const { Text } = require('react-native'); + return { Heading: ({ children }: MockChildrenProps) => {children} }; +}); + +jest.mock('@/components/ui/spinner', () => { + const { View } = require('react-native'); + return { Spinner: () => }; +}); + +jest.mock('@/components/ui/text', () => { + const { Text } = require('react-native'); + return { Text }; +}); + +const mockGetRecord = getRecord as jest.MockedFunction; +const mockUseRecordsStore = useRecordsStore as unknown as jest.Mock; + +const record = (rowVersion: number) => ({ + RecordId: 'r1', + DefinitionKey: 'shift-log', + DefinitionVersion: 3, + RecordNumber: 'SL-1', + State: RmsRecordState.Draft, + RowVersion: rowVersion, + Values: { Cells: [] }, +}); + +const store = { + fetchSchema: jest.fn(), + stageDraft: jest.fn(), + pushDraft: jest.fn(), + discardDraft: jest.fn(), + submitForReview: jest.fn(), + finalize: jest.fn(), + entryFor: jest.fn(() => null), +}; + +describe('RecordScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRecordsStore.mockImplementation((selector: (state: typeof store) => unknown) => selector(store)); + store.fetchSchema.mockResolvedValue({ Sections: [] }); + store.pushDraft.mockResolvedValue({ ok: true, recordId: 'r1' }); + store.submitForReview.mockResolvedValue({ ok: true }); + store.finalize.mockResolvedValue({ ok: true }); + mockGetRecord.mockResolvedValueOnce({ Data: record(4) } as never).mockResolvedValue({ Data: record(5) } as never); + }); + + it('submits the stored record as it is when nothing was edited', async () => { + const { unmount } = render(); + fireEvent.press(await screen.findByTestId('record-submit')); + + await waitFor(() => expect(store.submitForReview).toHaveBeenCalledWith('r1', 4)); + expect(store.pushDraft).not.toHaveBeenCalled(); + unmount(); + }); + + it('saves unsaved edits before submitting, and submits the version that save produced', async () => { + const { unmount } = render(); + fireEvent.press(await screen.findByTestId('record-form-edit')); + fireEvent.press(screen.getByTestId('record-submit')); + + await waitFor(() => expect(store.submitForReview).toHaveBeenCalledWith('r1', 5)); + expect(store.pushDraft).toHaveBeenCalledWith('edit-r1-4', expect.objectContaining({ recordId: 'r1', rowVersion: 4, values: [expect.objectContaining({ Value: 'Edited' })] })); + expect(store.pushDraft.mock.invocationCallOrder[0]).toBeLessThan(store.submitForReview.mock.invocationCallOrder[0]); + unmount(); + }); + + it('does not finalize when the unsaved edits could not be saved', async () => { + store.pushDraft.mockResolvedValue({ ok: false, conflict: 'etag' }); + + const { unmount } = render(); + fireEvent.press(await screen.findByTestId('record-form-edit')); + fireEvent.press(screen.getByTestId('record-attest')); + fireEvent.press(screen.getByTestId('record-finalize')); + + expect(await screen.findByText('records.conflict_etag')).toBeTruthy(); + expect(store.finalize).not.toHaveBeenCalled(); + unmount(); + }); + + it('locks the form while a save is in flight, so an edit cannot be lost to the reload that follows', async () => { + let finishSave: (result: { ok: boolean; recordId: string }) => void = () => undefined; + store.pushDraft.mockReturnValueOnce(new Promise((resolve) => (finishSave = resolve))); + const { unmount } = render(); + fireEvent.press(await screen.findByTestId('record-form-edit')); + + fireEvent.press(screen.getByTestId('record-save')); + + await waitFor(() => expect(screen.getByTestId('record-form-edit').props.accessibilityState).toMatchObject({ disabled: true })); + finishSave({ ok: true, recordId: 'r1' }); + await waitFor(() => expect(screen.getByTestId('record-form-edit').props.accessibilityState).toMatchObject({ disabled: false })); + unmount(); + }); +}); diff --git a/src/app/records/new.tsx b/src/app/records/new.tsx index ba50d834..32ac9c3b 100644 --- a/src/app/records/new.tsx +++ b/src/app/records/new.tsx @@ -148,9 +148,10 @@ export default function NewRecordScreen() { setIsBusy(true); try { - // Staged first so an interrupted send leaves the work on the device rather than losing it. + // Staged first so an interrupted send leaves the work on the device rather than losing it. A + // definition that seals values is not staged, so the draft is handed to the send directly. stageDraft(draft); - const result = await pushDraft(draftId); + const result = await pushDraft(draftId, draft); if (result.ok && result.recordId) { discardDraft(draftId); router.replace(`/records/${result.recordId}`); diff --git a/src/app/units/[id].tsx b/src/app/units/[id].tsx index dafa7676..123d4dc4 100644 --- a/src/app/units/[id].tsx +++ b/src/app/units/[id].tsx @@ -1,11 +1,13 @@ import { format } from 'date-fns'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { Building2, Circle, Clock, MapPin, Shield, Truck, User } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView, StyleSheet, View } from 'react-native'; import { getUnitsInfos } from '@/api/units/units'; +import { UdfFieldsRenderer } from '@/components/calls/udf-fields-renderer'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; import { Box } from '@/components/ui/box'; @@ -19,11 +21,15 @@ import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; +// The detail page only displays custom fields; nothing is edited here. +const ignoreUdfValues = () => {}; + export default function UnitDetail() { const { id } = useLocalSearchParams(); const unitId = Array.isArray(id) ? id[0] : id; const router = useRouter(); const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); const { trackEvent } = useAnalytics(); const [unit, setUnit] = useState(null); @@ -289,14 +295,8 @@ export default function UnitDetail() { {t('units.custom_fields')} - - {unit.UdfValues.map((udf, index) => ( - - {udf.UdfFieldId} - {udf.Value || '—'} - - ))} - + {/* Labels and option values come from the definition; the raw rows only carry field ids and stored keys. */} + ) : null} diff --git a/src/components/callVideoFeeds/video-player.tsx b/src/components/callVideoFeeds/video-player.tsx index ed3cb065..f4104144 100644 --- a/src/components/callVideoFeeds/video-player.tsx +++ b/src/components/callVideoFeeds/video-player.tsx @@ -232,7 +232,7 @@ const styles = StyleSheet.create({ flex: 1, }, loadingOverlay: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, alignItems: 'center', justifyContent: 'center', zIndex: 1, diff --git a/src/components/calls/__tests__/activity-link-marker.test.tsx b/src/components/calls/__tests__/activity-link-marker.test.tsx new file mode 100644 index 00000000..8b86ed8a --- /dev/null +++ b/src/components/calls/__tests__/activity-link-marker.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { type DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData'; + +import { ActivityLinkLegend, ActivityLinkMarker } from '../activity-link-marker'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe('ActivityLinkMarker', () => { + it.each([2, 3, 4])('renders the auto-linked marker with its explanation for source %p', (source) => { + render(); + + const marker = screen.getByTestId('activity-link-marker-auto'); + expect(screen.getByText('call_detail.activity_link.auto_linked')).toBeTruthy(); + expect(marker.props.accessibilityLabel).toBe('call_detail.activity_link.auto_linked'); + expect(marker.props.accessibilityHint).toBe('call_detail.activity_link.auto_linked_hint'); + expect(screen.queryByTestId('activity-link-marker-inferred')).toBeNull(); + }); + + it('renders the inferred marker with its explanation for source 5', () => { + render(); + + const marker = screen.getByTestId('activity-link-marker-inferred'); + expect(screen.getByText('call_detail.activity_link.inferred')).toBeTruthy(); + expect(marker.props.accessibilityHint).toBe('call_detail.activity_link.inferred_hint'); + expect(screen.queryByTestId('activity-link-marker-auto')).toBeNull(); + }); + + it.each([[1], [null], [undefined]])('renders nothing for source %p', (source) => { + const { toJSON } = render(); + expect(toJSON()).toBeNull(); + }); +}); + +describe('ActivityLinkLegend', () => { + const entry = (DestinationSource?: number | null) => ({ DestinationSource }) as DispatchedEventResultData; + + it('renders nothing when no entry is marked', () => { + const { toJSON } = render(); + expect(toJSON()).toBeNull(); + }); + + it('explains only the marker kinds present', () => { + render(); + + expect(screen.getByTestId('activity-link-legend')).toBeTruthy(); + expect(screen.getByText('call_detail.activity_link.auto_linked_hint')).toBeTruthy(); + expect(screen.queryByText('call_detail.activity_link.inferred_hint')).toBeNull(); + }); + + it('explains both kinds when both are present', () => { + render(); + + expect(screen.getByText('call_detail.activity_link.auto_linked_hint')).toBeTruthy(); + expect(screen.getByText('call_detail.activity_link.inferred_hint')).toBeTruthy(); + }); +}); diff --git a/src/components/calls/__tests__/call-notes-modal-new.test.tsx b/src/components/calls/__tests__/call-notes-modal-new.test.tsx index efd1c30c..7f2f8b04 100644 --- a/src/components/calls/__tests__/call-notes-modal-new.test.tsx +++ b/src/components/calls/__tests__/call-notes-modal-new.test.tsx @@ -11,7 +11,7 @@ jest.mock('@/lib/auth'); jest.mock('@/stores/calls/detail-store'); // Mock navigation -jest.mock('@react-navigation/native', () => ({ +jest.mock('expo-router', () => ({ useIsFocused: () => true, useNavigation: () => ({ navigate: jest.fn(), diff --git a/src/components/calls/__tests__/udf-fields-renderer.test.tsx b/src/components/calls/__tests__/udf-fields-renderer.test.tsx new file mode 100644 index 00000000..98a4a9e2 --- /dev/null +++ b/src/components/calls/__tests__/udf-fields-renderer.test.tsx @@ -0,0 +1,175 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { getUdfDefinition, getUdfValues } from '@/api/userDefinedFields/userDefinedFields'; +import { type UdfFieldResultData } from '@/models/v4/userDefinedFields/udfFieldResultData'; +import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput'; + +import { UdfFieldsRenderer } from '../udf-fields-renderer'; + +jest.mock('@/api/userDefinedFields/userDefinedFields', () => ({ + getUdfDefinition: jest.fn(), + getUdfValues: jest.fn(), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback ?? key, + }), +})); + +const mockGetUdfDefinition = getUdfDefinition as jest.MockedFunction; +const mockGetUdfValues = getUdfValues as jest.MockedFunction; + +const rules = (options: { Key: string; Label: string }[]) => JSON.stringify({ MinLength: null, Options: options }); + +const makeField = (overrides: Partial): UdfFieldResultData => ({ + UdfFieldId: 'f1', + UdfDefinitionId: 'def-1', + Name: 'field', + Label: 'Field', + Description: '', + Placeholder: '', + FieldDataType: 0, + IsRequired: false, + IsReadOnly: false, + DefaultValue: '', + ValidationRules: '', + SortOrder: 0, + GroupName: '', + IsVisibleOnMobile: true, + IsVisibleOnReports: true, + IsEnabled: true, + Visibility: 0, + ...overrides, +}); + +const outcome = makeField({ + UdfFieldId: 'outcome', + Name: 'outcome', + Label: 'Outcome', + FieldDataType: 11, + ValidationRules: rules([ + { Key: 'tx', Label: 'Transported' }, + { Key: 'refused', Label: 'Refused care' }, + ]), +}); + +const disposition = makeField({ + UdfFieldId: 'disposition', + Name: 'disposition', + Label: 'Disposition', + FieldDataType: 6, + SortOrder: 1, + ValidationRules: rules([ + { Key: 'p1', Label: 'Priority 1' }, + { Key: 'p2', Label: 'Priority 2' }, + ]), +}); + +const setDefinition = (fields: UdfFieldResultData[]) => { + mockGetUdfDefinition.mockResolvedValue({ Data: { UdfDefinitionId: 'def-1', DepartmentId: 1, EntityType: 0, Version: 1, IsActive: true, Fields: fields } } as unknown as Awaited>); +}; + +const setValues = (values: { UdfFieldId: string; Value: string }[]) => { + mockGetUdfValues.mockResolvedValue({ Data: values } as unknown as Awaited>); +}; + +const lastSubmitted = (onValuesChange: jest.Mock): Record => { + const calls = onValuesChange.mock.calls; + const values = calls[calls.length - 1][0] as UdfFieldValueInput[]; + return Object.fromEntries(values.map((v) => [v.UdfFieldId, v.Value])); +}; + +describe('UdfFieldsRenderer', () => { + beforeEach(() => { + jest.clearAllMocks(); + setValues([]); + }); + + it('renders dropdown options by label and stores the key', async () => { + setDefinition([disposition]); + const onValuesChange = jest.fn(); + const { unmount } = render(); + + await waitFor(() => expect(screen.getByText('Priority 2')).toBeTruthy()); + expect(screen.queryByText(/MinLength/)).toBeNull(); + + fireEvent.press(screen.getByText('Priority 2')); + expect(lastSubmitted(onValuesChange).disposition).toBe('p2'); + unmount(); + }); + + it('renders a combo box as a text input with suggestions', async () => { + setDefinition([outcome]); + const { unmount } = render(); + + await waitFor(() => expect(screen.getByTestId('udf-combo-input-outcome')).toBeTruthy()); + expect(screen.getByText('calls.udf_suggestions')).toBeTruthy(); + expect(screen.getByText('Transported')).toBeTruthy(); + expect(screen.getByText('Refused care')).toBeTruthy(); + unmount(); + }); + + it('stores a picked suggestion as its key and shows its label', async () => { + setDefinition([outcome]); + const onValuesChange = jest.fn(); + const { unmount } = render(); + + await waitFor(() => expect(screen.getByText('Refused care')).toBeTruthy()); + fireEvent.press(screen.getByText('Refused care')); + + expect(lastSubmitted(onValuesChange).outcome).toBe('refused'); + expect(screen.getByTestId('udf-combo-input-outcome').props.value).toBe('Refused care'); + unmount(); + }); + + it('sends free text as typed and narrows the suggestions', async () => { + setDefinition([outcome]); + const onValuesChange = jest.fn(); + const { unmount } = render(); + + await waitFor(() => expect(screen.getByTestId('udf-combo-input-outcome')).toBeTruthy()); + fireEvent.changeText(screen.getByTestId('udf-combo-input-outcome'), 'Ref'); + expect(screen.queryByText('Transported')).toBeNull(); + expect(screen.getByText('Refused care')).toBeTruthy(); + + fireEvent.changeText(screen.getByTestId('udf-combo-input-outcome'), 'Referred to crisis line'); + expect(lastSubmitted(onValuesChange).outcome).toBe('Referred to crisis line'); + expect(screen.queryByTestId('udf-combo-suggestions-outcome')).toBeNull(); + unmount(); + }); + + it('shows a stored combo key as its label', async () => { + setDefinition([outcome]); + setValues([{ UdfFieldId: 'outcome', Value: 'tx' }]); + const onValuesChange = jest.fn(); + const { unmount } = render(); + + await waitFor(() => expect(screen.getByTestId('udf-combo-input-outcome').props.value).toBe('Transported')); + expect(lastSubmitted(onValuesChange).outcome).toBe('tx'); + unmount(); + }); + + it('leaves a protected combo value untouched so the server keeps it', async () => { + setDefinition([outcome]); + setValues([{ UdfFieldId: 'outcome', Value: 'REDACTED' }]); + const onValuesChange = jest.fn(); + const { unmount } = render(); + + await waitFor(() => expect(screen.getByTestId('udf-combo-input-outcome').props.value).toBe('REDACTED')); + expect(lastSubmitted(onValuesChange).outcome).toBe('REDACTED'); + unmount(); + }); + + it('shows no suggestions and blocks editing when read-only', async () => { + setDefinition([outcome]); + setValues([{ UdfFieldId: 'outcome', Value: 'refused' }]); + const { unmount } = render(); + + await waitFor(() => expect(screen.getByTestId('udf-combo-input-outcome').props.value).toBe('Refused care')); + expect(screen.getByTestId('udf-combo-input-outcome').props.editable).toBe(false); + expect(screen.queryByTestId('udf-combo-suggestions-outcome')).toBeNull(); + unmount(); + }); +}); diff --git a/src/components/calls/activity-link-marker.tsx b/src/components/calls/activity-link-marker.tsx new file mode 100644 index 00000000..c221f6fa --- /dev/null +++ b/src/components/calls/activity-link-marker.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Platform } from 'react-native'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { type ActivityLinkKind, getActivityLinkKind, getActivityLinkKinds } from '@/lib/activity-link'; +import { type DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData'; + +const MARKER_TEXT: Record = { + auto: { label: 'call_detail.activity_link.auto_linked', hint: 'call_detail.activity_link.auto_linked_hint', action: 'info' }, + inferred: { label: 'call_detail.activity_link.inferred', hint: 'call_detail.activity_link.inferred_hint', action: 'warning' }, +}; + +const MarkerBadge: React.FC<{ kind: ActivityLinkKind; decorative?: boolean }> = ({ kind, decorative = false }) => { + const { t } = useTranslation(); + const { label, hint, action } = MARKER_TEXT[kind]; + const labelText = t(label); + const hintText = t(hint); + + if (decorative) { + return ( + + {labelText} + + ); + } + + return ( + + {labelText} + + ); +}; + +/** + * Marks a call-activity status entry the server linked on the sender's behalf ("auto-linked") or attributed to + * the call at read time ("inferred"). Renders nothing for explicit entries, older rows and non-status entries. + */ +export const ActivityLinkMarker: React.FC<{ source?: number | null }> = React.memo(({ source }) => { + const kind = getActivityLinkKind(source); + return kind ? : null; +}); + +ActivityLinkMarker.displayName = 'ActivityLinkMarker'; + +/** One line per marker kind present in the list, explaining it; renders nothing when no entry is marked. */ +export const ActivityLinkLegend: React.FC<{ activity?: DispatchedEventResultData[] | null }> = React.memo(({ activity }) => { + const { t } = useTranslation(); + const kinds = React.useMemo(() => getActivityLinkKinds(activity), [activity]); + if (kinds.length === 0) return null; + + return ( + + {kinds.map((kind) => { + const labelText = t(MARKER_TEXT[kind].label); + const hintText = t(MARKER_TEXT[kind].hint); + return ( + + + {hintText} + + ); + })} + + ); +}); + +ActivityLinkLegend.displayName = 'ActivityLinkLegend'; diff --git a/src/components/calls/call-audio-modal.tsx b/src/components/calls/call-audio-modal.tsx index 138df63a..a4d4e300 100644 --- a/src/components/calls/call-audio-modal.tsx +++ b/src/components/calls/call-audio-modal.tsx @@ -1,6 +1,6 @@ import type { BottomSheetBackdropProps } from '@gorhom/bottom-sheet'; import BottomSheet, { BottomSheetBackdrop, BottomSheetView } from '@gorhom/bottom-sheet'; -import { Audio, type AVPlaybackStatus } from 'expo-av'; +import { type AudioPlayer, type AudioStatus, createAudioPlayer, setAudioModeAsync } from 'expo-audio'; import { Music, PauseIcon, PlayIcon, X } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -33,7 +33,7 @@ export const CallAudioModal: React.FC = ({ isOpen, onClose, const { callAudio, isLoadingAudio, errorAudio, fetchCallAudio } = useCallDetailStore(); const [playingId, setPlayingId] = useState(null); const [loadingId, setLoadingId] = useState(null); - const soundRef = useRef(null); + const soundRef = useRef(null); // Monotonic id bumped on every play/stop tap so overlapping async loads can tell // whether they are still the latest request before committing state. const playRequestRef = useRef(0); @@ -44,9 +44,9 @@ export const CallAudioModal: React.FC = ({ isOpen, onClose, const unloadSound = useCallback(async () => { if (soundRef.current) { try { - await soundRef.current.unloadAsync(); + soundRef.current.remove(); } catch { - // ignore — sound may already be unloaded + // ignore — player may already be released } soundRef.current = null; } @@ -108,22 +108,25 @@ export const CallAudioModal: React.FC = ({ isOpen, onClose, try { setLoadingId(file.Id); await unloadSound(); - await Audio.setAudioModeAsync({ playsInSilentModeIOS: true, staysActiveInBackground: false }); - const { sound } = await Audio.Sound.createAsync({ uri: file.Url.trim() }, { shouldPlay: true, volume: 1.0 }, (status: AVPlaybackStatus) => { - if (status.isLoaded && status.didJustFinish) { - void unloadSound(); - } - }); + await setAudioModeAsync({ playsInSilentMode: true, shouldPlayInBackground: false }); - // A newer tap arrived while this clip was loading — discard this sound so it - // can't overwrite soundRef.current or start overlapping playback. + // A newer tap arrived while the audio session was being configured — bail out so + // this clip can't overwrite soundRef.current or start overlapping playback. if (requestId !== playRequestRef.current) { - await sound.unloadAsync().catch(() => {}); return; } + const sound = createAudioPlayer({ uri: file.Url.trim() }); + sound.volume = 1.0; + sound.addListener('playbackStatusUpdate', (status: AudioStatus) => { + if (status.didJustFinish && soundRef.current === sound) { + void unloadSound(); + } + }); + soundRef.current = sound; setPlayingId(file.Id); + sound.play(); } catch (error) { logger.error({ message: 'Failed to play call audio', context: { error, callId } }); } finally { diff --git a/src/components/calls/udf-fields-renderer.tsx b/src/components/calls/udf-fields-renderer.tsx index fb339fa5..2055a962 100644 --- a/src/components/calls/udf-fields-renderer.tsx +++ b/src/components/calls/udf-fields-renderer.tsx @@ -1,28 +1,16 @@ -import { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, StyleSheet, TextInput, View } from 'react-native'; +import { ActivityIndicator, type StyleProp, StyleSheet, TextInput, type TextStyle, View } from 'react-native'; import { getUdfDefinition, getUdfValues } from '@/api/userDefinedFields/userDefinedFields'; +import { comboDisplayText, filterComboSuggestions, findComboOption, parseUdfOptions, selectedOptionKeys, UDF_FIELD_TYPE, type UdfOption } from '@/lib/udf/options'; import { type UdfFieldResultData } from '@/models/v4/userDefinedFields/udfFieldResultData'; import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput'; import { type UdfFieldValueResultData } from '@/models/v4/userDefinedFields/udfFieldValueResultData'; import { Text } from '../ui/text'; -// FieldDataType enum -const UDF_FIELD_TYPE = { - Text: 0, - Number: 1, - Decimal: 2, - Boolean: 3, - Date: 4, - DateTime: 5, - Dropdown: 6, - MultiSelect: 7, - Email: 8, - Phone: 9, - Url: 10, -} as const; +const EMPTY_OPTIONS: UdfOption[] = []; interface UdfFieldsRendererProps { entityType: number; @@ -117,6 +105,15 @@ export const UdfFieldsRenderer: React.FC = ({ entityType [fields, notifyChange] ); + // Parsed once per definition so option lists keep their identity across renders. + const optionsByField = useMemo(() => { + const map: Record = {}; + fields.forEach((f) => { + map[f.UdfFieldId] = parseUdfOptions(f.ValidationRules); + }); + return map; + }, [fields]); + if (isLoading) { return ( @@ -154,7 +151,7 @@ export const UdfFieldsRenderer: React.FC = ({ entityType case UDF_FIELD_TYPE.Dropdown: case UDF_FIELD_TYPE.MultiSelect: { - const opts = field.Options && field.Options.length > 0 ? field.Options : field.ValidationRules ? field.ValidationRules.split(',').map((o) => o.trim()) : []; + const opts = optionsByField[field.UdfFieldId] ?? EMPTY_OPTIONS; return ( @@ -175,6 +172,29 @@ export const UdfFieldsRenderer: React.FC = ({ entityType ); } + case UDF_FIELD_TYPE.ComboBox: + return ( + + + {label} + {field.IsRequired ? * : null} + + {field.Description ? {field.Description} : null} + + + ); + case UDF_FIELD_TYPE.Number: case UDF_FIELD_TYPE.Decimal: return ( @@ -339,7 +359,7 @@ const BooleanToggle: React.FC = ({ value, onChange, disabled }; interface OptionSelectorProps { - options: string[]; + options: UdfOption[]; value: string; multiSelect: boolean; onChange: (v: string) => void; @@ -348,37 +368,38 @@ interface OptionSelectorProps { placeholder?: string; } +// Chips show each option's label and store its key, the way the server stores a dropdown/multi-select value. const OptionSelector: React.FC = ({ options, value, multiSelect, onChange, disabled = false, isDark = false }) => { - const selectedValues = value ? value.split(',').map((v) => v.trim()) : []; + const selectedKeys = selectedOptionKeys(multiSelect ? UDF_FIELD_TYPE.MultiSelect : UDF_FIELD_TYPE.Dropdown, value); - const toggle = (opt: string) => { + const toggle = (key: string) => { if (disabled) return; if (multiSelect) { - const next = selectedValues.includes(opt) ? selectedValues.filter((v) => v !== opt) : [...selectedValues, opt]; + const next = selectedKeys.includes(key) ? selectedKeys.filter((k) => k !== key) : [...selectedKeys, key]; onChange(next.join(',')); } else { - onChange(selectedValues.includes(opt) ? '' : opt); + onChange(selectedKeys.includes(key) ? '' : key); } }; return ( {options.map((opt) => { - const isSelected = selectedValues.includes(opt); + const isSelected = selectedKeys.includes(opt.key); return ( toggle(opt)} + onPress={() => toggle(opt.key)} accessibilityRole="button" - accessibilityState={{ selected: isSelected }} + accessibilityState={{ selected: isSelected, disabled }} > - {opt} + {opt.label} ); })} @@ -386,6 +407,95 @@ const OptionSelector: React.FC = ({ options, value, multiSe ); }; +interface ComboBoxInputProps { + fieldId: string; + options: UdfOption[]; + value: string; + onChange: (fieldId: string, value: string) => void; + disabled?: boolean; + isDark?: boolean; + label: string; + placeholder?: string; + inputStyle: StyleProp; + captionStyle: StyleProp; +} + +// A text input with the field's options offered as suggestions. Picking a suggestion shows its label and stores its +// key; anything typed is sent as typed and the server stores a typed label as that option's key. +const ComboBoxInput: React.FC = ({ fieldId, options, value, onChange, disabled = false, isDark = false, label, placeholder, inputStyle, captionStyle }) => { + const { t } = useTranslation(); + // Held locally so typing a string that happens to equal an option key does not flip the input to that label mid-word. + const [text, setText] = useState(() => comboDisplayText(options, value)); + const suggestions = useMemo(() => filterComboSuggestions(options, text), [options, text]); + const selectedKey = findComboOption(options, text)?.key; + + const handleChangeText = useCallback( + (next: string) => { + setText(next); + onChange(fieldId, next); + }, + [fieldId, onChange] + ); + + const handleSelect = useCallback( + (option: UdfOption) => { + if (disabled) return; + setText(option.label); + onChange(fieldId, option.key); + }, + [disabled, fieldId, onChange] + ); + + return ( + + + {!disabled && suggestions.length > 0 ? ( + + {t('calls.udf_suggestions')} + + {suggestions.map((option) => ( + + ))} + + + ) : null} + + ); +}; + +interface ComboSuggestionProps { + option: UdfOption; + isSelected: boolean; + onSelect: (option: UdfOption) => void; + isDark: boolean; +} + +const ComboSuggestion: React.FC = React.memo(({ option, isSelected, onSelect, isDark }) => { + const handlePress = useCallback(() => onSelect(option), [onSelect, option]); + return ( + + {option.label} + + ); +}); + +ComboSuggestion.displayName = 'ComboSuggestion'; + const styles = StyleSheet.create({ container: { width: '100%', @@ -530,4 +640,7 @@ const styles = StyleSheet.create({ optionDisabled: { opacity: 0.5, }, + suggestionsBlock: { + marginTop: 8, + }, }); diff --git a/src/components/contacts/__tests__/contact-details-extra.test.tsx b/src/components/contacts/__tests__/contact-details-extra.test.tsx new file mode 100644 index 00000000..c52d2990 --- /dev/null +++ b/src/components/contacts/__tests__/contact-details-extra.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { type ContactResultData, ContactType } from '@/models/v4/contacts/contactResultData'; + +import { ContactDetailsExtra } from '../contact-details-extra'; + +jest.mock('@/stores/contacts/store', () => ({ + useContactsStore: { + getState: () => ({ + fetchContactNotes: jest.fn().mockResolvedValue(undefined), + fetchContactDetails: jest.fn().mockResolvedValue(undefined), + }), + }, +})); + +jest.mock('@/components/data-protection/protected-reveal-bar', () => ({ + ProtectedRevealBar: () => null, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const makeContact = (customFields: ContactResultData['CustomFields']): ContactResultData => + ({ + ContactId: 'contact-1', + ContactType: ContactType.Person, + Name: 'Jane Doe', + CustomFields: customFields, + }) as ContactResultData; + +describe('ContactDetailsExtra custom fields', () => { + it('shows the display value the server resolved for option fields', () => { + const contact = makeContact([ + { UdfFieldId: 'outcome', Label: 'Outcome', Value: 'tx', DisplayValue: 'Transported', FieldDataType: 11, GroupName: null, SortOrder: 0 }, + { UdfFieldId: 'priority', Label: 'Priority', Value: 'p1', DisplayValue: 'Priority 1', FieldDataType: 6, GroupName: 'Triage', SortOrder: 1 }, + ]); + + const { unmount } = render(); + + expect(screen.getByText('Transported')).toBeTruthy(); + expect(screen.getByText('Priority 1')).toBeTruthy(); + expect(screen.getByText('Triage · Priority')).toBeTruthy(); + expect(screen.queryByText('tx')).toBeNull(); + unmount(); + }); + + it('falls back to the stored value when the server sends no display value', () => { + const contact = makeContact([{ UdfFieldId: 'notes', Label: 'Notes', Value: 'Referred to crisis line', FieldDataType: 11, SortOrder: 0 }]); + + const { unmount } = render(); + + expect(screen.getByText('Referred to crisis line')).toBeTruthy(); + unmount(); + }); + + it('hides custom fields when the host renders its own', () => { + const contact = makeContact([{ UdfFieldId: 'outcome', Label: 'Outcome', Value: 'tx', DisplayValue: 'Transported', FieldDataType: 11, SortOrder: 0 }]); + + const { unmount } = render(); + + expect(screen.queryByTestId('contact-custom-fields')).toBeNull(); + unmount(); + }); +}); diff --git a/src/components/contacts/__tests__/contact-details-sheet.test.tsx b/src/components/contacts/__tests__/contact-details-sheet.test.tsx index 2a699b55..0f1a7ae2 100644 --- a/src/components/contacts/__tests__/contact-details-sheet.test.tsx +++ b/src/components/contacts/__tests__/contact-details-sheet.test.tsx @@ -1,3 +1,4 @@ +jest.mock('../contact-details-extra', () => ({ ContactDetailsExtra: () => null })); import { render, fireEvent } from '@testing-library/react-native'; import React from 'react'; diff --git a/src/components/contacts/__tests__/contact-files-list.test.tsx b/src/components/contacts/__tests__/contact-files-list.test.tsx index 2ca56ec4..e25194cb 100644 --- a/src/components/contacts/__tests__/contact-files-list.test.tsx +++ b/src/components/contacts/__tests__/contact-files-list.test.tsx @@ -1,8 +1,14 @@ import { describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import React from 'react'; +import { Alert, Platform } from 'react-native'; -import { safeFileName } from '../contact-files-list'; +import { getContactFileBase64 } from '@/api/contacts/contactFiles'; +import { type ContactFileResultData } from '@/models/v4/contactFiles/contactFilesResult'; -jest.mock('@/hooks/use-analytics'); +import { ContactFilesList, safeFileName } from '../contact-files-list'; + +jest.mock('@/hooks/use-analytics', () => ({ useAnalytics: () => ({ trackEvent: jest.fn() }) })); jest.mock('@/api/contacts/contactFiles', () => ({ getContactFileBase64: jest.fn() })); jest.mock('expo-file-system/legacy', () => ({ documentDirectory: 'file:///documents/', EncodingType: { Base64: 'base64' }, writeAsStringAsync: jest.fn() })); jest.mock('expo-sharing', () => ({ isAvailableAsync: jest.fn(), shareAsync: jest.fn() })); @@ -40,3 +46,28 @@ describe('safeFileName', () => { expect(safeFileName(' ', 'contact_file_1')).toBe('contact_file_1'); }); }); + +describe('ContactFilesList download failure', () => { + const file = { Id: 'f-1', ContactId: 'c-1', Type: 1, TypeName: 'Plan', Name: 'Floor plan', FileName: 'plan.pdf', Mime: 'application/pdf', Size: 2048, Timestamp: '', IsProtected: false } as ContactFileResultData; + + it('tells the dispatcher in the browser, where Alert.alert does nothing', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + const browserAlert = jest.fn(); + const nativeAlert = jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); + const previousAlert = window.alert; + window.alert = browserAlert; + jest.mocked(getContactFileBase64).mockRejectedValue(new Error('offline')); + + try { + render(); + fireEvent.press(screen.getByTestId('contact-file-download-f-1')); + + await waitFor(() => expect(browserAlert).toHaveBeenCalledWith('contacts.files.download_failed')); + expect(nativeAlert).not.toHaveBeenCalled(); + } finally { + window.alert = previousAlert; + nativeAlert.mockRestore(); + jest.restoreAllMocks(); + } + }); +}); diff --git a/src/components/contacts/contact-details-extra.tsx b/src/components/contacts/contact-details-extra.tsx new file mode 100644 index 00000000..af6f1db0 --- /dev/null +++ b/src/components/contacts/contact-details-extra.tsx @@ -0,0 +1,120 @@ +import { LockIcon } from 'lucide-react-native'; +import React, { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Linking, View } from 'react-native'; + +import { ProtectedRevealBar } from '@/components/data-protection/protected-reveal-bar'; +import { HStack } from '@/components/ui/hstack'; +import { HomeIcon, MapPinIcon, SettingsIcon } from '@/components/ui/lucide-icons'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { mapsUrls } from '@/lib/contacts/format'; +import { logger } from '@/lib/logging'; +import { type ContactAddressData, type ContactResultData } from '@/models/v4/contacts/contactResultData'; +import { useContactsStore } from '@/stores/contacts/store'; + +interface ContactDetailsExtraProps { + contact: ContactResultData & { WithheldFields?: string[] }; + /** Dispatch renders custom fields through its own UDF renderer. */ + showCustomFields?: boolean; +} + +const openMaps = async (query: string) => { + const { app, web } = mapsUrls(query); + try { + if (app && (await Linking.canOpenURL(app))) { + await Linking.openURL(app); + return; + } + await Linking.openURL(web); + } catch (error) { + logger.warn({ message: 'Could not open maps for contact address', context: { error } }); + } +}; + +const AddressRow = ({ label, address }: { label: string; address: ContactAddressData }) => { + const line = address.Formatted || [address.Address1, address.City, address.State, address.PostalCode, address.Country].filter(Boolean).join(', '); + if (!line) return null; + return ( + void openMaps(line)} accessibilityRole="link" accessibilityLabel={`${label}, ${line}`} testID="contact-address"> + + + + + + {label} + {line} + + + + ); +}; + +// What the slim contact list never carried, for every field app's detail sheet: the resolved physical and +// mailing addresses (tap to open in maps), the category, the department's mobile-visible custom fields, and +// — for a protected department — which details were withheld with the reveal that re-reads them. +export const ContactDetailsExtra: React.FC = ({ contact, showCustomFields = true }) => { + const { t } = useTranslation(); + const contactId = contact.ContactId; + + // Opening a contact reads its notes fresh: a cached copy may predate an unlock and still be redacted. + useEffect(() => { + void useContactsStore.getState().fetchContactNotes(contactId, true); + }, [contactId]); + + const refresh = useCallback(async () => { + const store = useContactsStore.getState(); + await Promise.all([store.fetchContactDetails(contactId), store.fetchContactNotes(contactId, true)]); + }, [contactId]); + + const withheld = contact.WithheldFields ?? []; + const category = contact.CategoryName || contact.Category?.Name; + const customFields = showCustomFields ? (contact.CustomFields ?? []) : []; + + return ( + + {contact.IsProtected || withheld.length > 0 ? ( + + {withheld.length > 0 ? ( + + + {t('contacts.withheld', { count: withheld.length })} + + ) : null} + + + ) : null} + {category ? ( + + + {t('contacts.categoryLabel', { name: category })} + + ) : null} + {contact.PhysicalAddress ? : null} + {contact.MailingAddress ? : null} + {contact.LocationGeofence ? ( + + + + + {t('contacts.hasGeofence')} + + ) : null} + {customFields.length > 0 ? ( + + + + {t('contacts.customFields')} + + {customFields.map((field) => ( + + {field.GroupName ? `${field.GroupName} · ${field.Label}` : field.Label} + {field.DisplayValue || field.Value} + + ))} + + ) : null} + + ); +}; diff --git a/src/components/contacts/contact-details-sheet.tsx b/src/components/contacts/contact-details-sheet.tsx index 4febaeac..e98aa67a 100644 --- a/src/components/contacts/contact-details-sheet.tsx +++ b/src/components/contacts/contact-details-sheet.tsx @@ -23,6 +23,7 @@ import { X, } from '@/components/ui/lucide-icons'; import { useAnalytics } from '@/hooks/use-analytics'; +import { htmlToText, withoutRedacted } from '@/lib/contacts/format'; import { ContactType } from '@/models/v4/contacts/contactResultData'; import { useContactsStore } from '@/stores/contacts/store'; @@ -33,6 +34,7 @@ import { HStack } from '../ui/hstack'; import { Pressable } from '../ui/pressable'; import { Text } from '../ui/text'; import { VStack } from '../ui/vstack'; +import { ContactDetailsExtra } from './contact-details-extra'; import { ContactFilesPanel } from './contact-files-panel'; import { ContactNotesList } from './contact-notes-list'; import { ContactPreplanPanel } from './contact-preplan-panel'; @@ -91,13 +93,17 @@ export const ContactDetailsSheet: React.FC = () => { const { trackEvent } = useAnalytics(); const { width, height } = useWindowDimensions(); const isLandscape = width > height; - const { contacts, selectedContactId, isDetailsOpen, closeDetails } = useContactsStore(); + const { contacts, selectedContactId, selectedContactDetails, isDetailsOpen, closeDetails } = useContactsStore(); const [activeTab, setActiveTab] = useState<'details' | 'notes' | 'preplan' | 'files'>('details'); const selectedContact = React.useMemo(() => { if (!selectedContactId) return null; - return contacts.find((contact) => contact.ContactId === selectedContactId); - }, [contacts, selectedContactId]); + // Prefer the full GetContactById record — the list payload is slim (no address, custom fields or + // category) — and fall back to the list row while it loads. Withheld values are removed so the + // sheet never shows the REDACTED sentinel as data or offers to dial it. + const record = selectedContactDetails?.ContactId === selectedContactId ? selectedContactDetails : contacts.find((contact) => contact.ContactId === selectedContactId); + return record ? withoutRedacted(record) : null; + }, [contacts, selectedContactId, selectedContactDetails]); // Track when contact details sheet is opened/rendered React.useEffect(() => { @@ -203,7 +209,9 @@ export const ContactDetailsSheet: React.FC = () => { {selectedContact.ContactType === ContactType.Person ? t('contacts.person') : t('contacts.company')} {selectedContact.OtherName ? ({selectedContact.OtherName}) : null} - {selectedContact.Category?.Name ? {selectedContact.Category.Name} : null} + {selectedContact.CategoryName || selectedContact.Category?.Name ? ( + {selectedContact.CategoryName || selectedContact.Category?.Name} + ) : null} @@ -235,6 +243,7 @@ export const ContactDetailsSheet: React.FC = () => { {activeTab === 'details' ? ( + {/* Contact Information Section */} {hasContactInfo ? (
}> @@ -302,9 +311,9 @@ export const ContactDetailsSheet: React.FC = () => { {hasAdditionalInfo ? (
} defaultExpanded={false}> - - - + + +
) : null} diff --git a/src/components/contacts/contact-files-list.tsx b/src/components/contacts/contact-files-list.tsx index 49aab520..104d824f 100644 --- a/src/components/contacts/contact-files-list.tsx +++ b/src/components/contacts/contact-files-list.tsx @@ -3,7 +3,7 @@ import * as Sharing from 'expo-sharing'; import { DownloadIcon, FileIcon, LockIcon, PaperclipIcon } from 'lucide-react-native'; import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Alert } from 'react-native'; +import { Alert, Platform } from 'react-native'; import { getContactFileBase64 } from '@/api/contacts/contactFiles'; import { ProtectedText } from '@/components/data-protection/protected-text'; @@ -73,6 +73,28 @@ export const ContactFilesList: React.FC = ({ files, isLoa const base64 = await getContactFileBase64(file); const fallbackName = `contact_file_${file.Id}`; const fileName = safeFileName(isFieldRedacted(file.RedactedFields, FileFieldIds.fileName, file.FileName) ? null : file.FileName, fallbackName); + + if (Platform.OS === 'web') { + // The dispatch console runs in a browser: there is no document directory or share sheet, so the + // bytes become a browser download. The object URL is always revoked, even when the click throws. + let objectUrl: string | null = null; + let link: HTMLAnchorElement | null = null; + try { + const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); + objectUrl = URL.createObjectURL(new Blob([bytes], { type: file.Mime || 'application/octet-stream' })); + link = document.createElement('a'); + link.href = objectUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + } finally { + link?.remove(); + if (objectUrl) URL.revokeObjectURL(objectUrl); + } + trackEvent('contact_file_download_completed', { contextId: contextId ?? '', fileId: file.Id, wasShared: false }); + return; + } + const fileUri = `${FileSystem.documentDirectory}${fileName}`; await FileSystem.writeAsStringAsync(fileUri, base64, { encoding: FileSystem.EncodingType.Base64 }); @@ -85,7 +107,12 @@ export const ContactFilesList: React.FC = ({ files, isLoa } } catch (error) { logger.error({ message: 'Failed to download contact file', context: { error, fileId: file.Id } }); - Alert.alert(t('contacts.files.download_failed')); + if (Platform.OS === 'web') { + // React Native Web's Alert.alert does nothing, so the browser reports the failure itself. + window.alert(t('contacts.files.download_failed')); + } else { + Alert.alert(t('contacts.files.download_failed')); + } } finally { setDownloading((prev) => { const next = { ...prev }; diff --git a/src/components/dispatch-console/__tests__/activity-log-panel-actions.test.tsx b/src/components/dispatch-console/__tests__/activity-log-panel-actions.test.tsx new file mode 100644 index 00000000..db3b96f5 --- /dev/null +++ b/src/components/dispatch-console/__tests__/activity-log-panel-actions.test.tsx @@ -0,0 +1,114 @@ +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; +import { useDashboardViewStore } from '@/stores/dispatch/dashboard-view-store'; +import { useUnitActionsStore } from '@/stores/dispatch/unit-actions-store'; + +import { ActivityLogPanel } from '../activity-log-panel'; + +jest.mock('expo-router', () => ({ router: { push: jest.fn() } })); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('@/stores/dispatch/dashboard-view-store', () => { + const { create } = require('zustand'); + const useDashboardViewStore = create((set: any) => ({ + collapsedCards: {}, + setCardCollapsed: (card: string, collapsed: boolean) => set((state: any) => ({ collapsedCards: { ...state.collapsedCards, [card]: collapsed } })), + })); + return { useDashboardViewStore, selectCardCollapsed: (card: string) => (state: any) => state.collapsedCards[card] ?? false }; +}); + +jest.mock('@/stores/checkIn/store', () => ({ + useCheckInStore: (selector: (state: unknown) => unknown) => selector({ timerStatuses: [], isLoadingStatuses: false }), +})); + +jest.mock('@/stores/weatherAlerts/store', () => ({ + useWeatherAlertsStore: (selector: (state: unknown) => unknown) => selector({ alerts: [], settings: null }), +})); + +jest.mock('@/hooks/use-check-in-timer-polling', () => ({ useCheckInTimerPolling: jest.fn() })); +jest.mock('@/components/checkIn/check-in-bottom-sheet', () => ({ CheckInBottomSheet: () => null })); +jest.mock('@/components/checkIn/check-in-timer-card', () => ({ CheckInTimerCard: () => null })); +jest.mock('@/components/ui/icon', () => ({ Icon: () => null })); + +jest.mock('../panel-header', () => ({ + PanelHeader: ({ title }: { title: string }) => { + const { Text } = require('react-native'); + return {title}; + }, +})); + +// The actions panels themselves are covered by their own tests; here only which one is shown matters +jest.mock('../unit-actions-panel', () => ({ + UnitActionsPanel: ({ unit }: { unit: UnitInfoResultData }) => { + const { Text } = require('react-native'); + return {`unit-actions:${unit.UnitId}`}; + }, +})); + +jest.mock('../personnel-actions-panel', () => ({ + PersonnelActionsPanel: () => null, +})); + +const call = { CallId: '777', Number: '26-777', Name: 'MVA', State: 0 } as CallResultData; +const engine = { UnitId: 'e1', Name: 'Engine 1' } as UnitInfoResultData; + +const baseProps = { entries: [], isLoading: false, isCallFilterActive: true, selectedCallId: '777' }; + +describe('ActivityLogPanel actions for "+" set-status-for-call', () => { + beforeEach(() => { + useUnitActionsStore.getState().reset(); + useDashboardViewStore.setState({ collapsedCards: {} } as any); + }); + + it('keeps the call context when the unit selection arrives after "+" opened the panel', () => { + const view = render(); + + // What the "+" handler does: open the store with the call context, then select the unit + act(() => { + useUnitActionsStore.getState().openActions(engine, { callContext: call }); + }); + view.rerender(); + + expect(useUnitActionsStore.getState().callContext).toBe(call); + expect(useUnitActionsStore.getState().statusSelectedCall).toBe(call); + expect(screen.getByText('unit-actions:e1')).toBeTruthy(); + }); + + it('surfaces the actions tab (and expands the card) when "+" re-targets the already-selected unit', () => { + const view = render(); + expect(screen.getByText('unit-actions:e1')).toBeTruthy(); + const session = useUnitActionsStore.getState().actionsSessionId; + + // Dispatcher goes back to the activity tab, then presses "+" on the same unit + fireEvent.press(screen.getByText('dispatch.activity')); + expect(screen.queryByText('unit-actions:e1')).toBeNull(); + act(() => { + useDashboardViewStore.getState().setCardCollapsed('activity-log', true); + }); + act(() => { + useUnitActionsStore.getState().openActions(engine, { callContext: call }); + }); + view.rerender(); + + expect(useUnitActionsStore.getState().actionsSessionId).toBe(session + 1); + expect(useDashboardViewStore.getState().collapsedCards['activity-log']).toBe(false); + expect(screen.getByText('unit-actions:e1')).toBeTruthy(); + }); + + it('still opens the unit actions (without a call context) on a plain unit selection', () => { + const view = render(); + view.rerender(); + + const state = useUnitActionsStore.getState(); + expect(state.isActionsOpen).toBe(true); + expect(state.selectedUnit).toBe(engine); + expect(state.callContext).toBeNull(); + expect(screen.getByText('unit-actions:e1')).toBeTruthy(); + }); +}); diff --git a/src/components/dispatch-console/__tests__/activity-log-panel-link-markers.test.tsx b/src/components/dispatch-console/__tests__/activity-log-panel-link-markers.test.tsx new file mode 100644 index 00000000..38115a75 --- /dev/null +++ b/src/components/dispatch-console/__tests__/activity-log-panel-link-markers.test.tsx @@ -0,0 +1,89 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { type DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData'; + +import { ActivityLogPanel } from '../activity-log-panel'; + +jest.mock('expo-router', () => ({ router: { push: jest.fn() } })); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('@/stores/dispatch/dashboard-view-store', () => ({ + useDashboardViewStore: (selector: (state: unknown) => unknown) => selector({ collapsedCards: {}, setCardCollapsed: jest.fn() }), + selectCardCollapsed: () => () => false, +})); + +jest.mock('@/stores/checkIn/store', () => ({ + useCheckInStore: (selector: (state: unknown) => unknown) => selector({ timerStatuses: [], isLoadingStatuses: false }), +})); + +jest.mock('@/stores/weatherAlerts/store', () => ({ + useWeatherAlertsStore: (selector: (state: unknown) => unknown) => selector({ alerts: [], settings: null }), +})); + +jest.mock('@/hooks/use-check-in-timer-polling', () => ({ useCheckInTimerPolling: jest.fn() })); +jest.mock('@/components/checkIn/check-in-bottom-sheet', () => ({ CheckInBottomSheet: () => null })); +jest.mock('@/components/checkIn/check-in-timer-card', () => ({ CheckInTimerCard: () => null })); +jest.mock('@/components/ui/icon', () => ({ Icon: () => null })); +jest.mock('../panel-header', () => ({ PanelHeader: () => null })); +jest.mock('../unit-actions-panel', () => ({ UnitActionsPanel: () => null })); +jest.mock('../personnel-actions-panel', () => ({ PersonnelActionsPanel: () => null })); + +const activity = (Id: string, StatusText: string, DestinationSource?: number | null): DispatchedEventResultData => ({ + Id, + Timestamp: '2026-09-23 10:00', + Type: 'Unit', + Name: `Engine ${Id}`, + GroupId: '1', + Group: 'Station 1', + Note: '', + StatusId: 2, + Location: '', + StatusText, + StatusColor: '#3b82f6', + DestinationSource, +}); + +const renderPanel = (callActivity: DispatchedEventResultData[]) => { + const view = render(); + // Selecting a call jumps the panel to its actions tab; the call's activity lives on the activity tab + fireEvent.press(screen.getByText('dispatch.activity')); + return view; +}; + +describe('ActivityLogPanel call activity link markers', () => { + it.each([2, 3, 4])('marks an entry auto-linked for source %p', (source) => { + renderPanel([activity('1', 'Responding', source)]); + + expect(screen.getByText('Responding')).toBeTruthy(); + expect(screen.getByTestId('activity-link-marker-auto')).toBeTruthy(); + expect(screen.queryByTestId('activity-link-marker-inferred')).toBeNull(); + expect(screen.getByTestId('activity-link-legend')).toBeTruthy(); + }); + + it('marks an entry inferred for source 5', () => { + renderPanel([activity('1', 'On Scene', 5)]); + + expect(screen.getByTestId('activity-link-marker-inferred')).toBeTruthy(); + expect(screen.queryByTestId('activity-link-marker-auto')).toBeNull(); + }); + + it.each([[1], [null], [undefined]])('shows no marker or legend for source %p', (source) => { + renderPanel([activity('1', 'Available', source)]); + + expect(screen.getByText('Available')).toBeTruthy(); + expect(screen.queryByTestId('activity-link-marker-auto')).toBeNull(); + expect(screen.queryByTestId('activity-link-marker-inferred')).toBeNull(); + expect(screen.queryByTestId('activity-link-legend')).toBeNull(); + }); + + it('marks only the entries the server linked or inferred in a mixed list', () => { + renderPanel([activity('1', 'Dispatched', 1), activity('2', 'Responding', 3), activity('3', 'On Scene', 5), activity('4', 'Available', null)]); + + expect(screen.getAllByTestId('activity-link-marker-auto')).toHaveLength(1); + expect(screen.getAllByTestId('activity-link-marker-inferred')).toHaveLength(1); + }); +}); diff --git a/src/components/dispatch-console/__tests__/personnel-actions-panel.test.tsx b/src/components/dispatch-console/__tests__/personnel-actions-panel.test.tsx new file mode 100644 index 00000000..da4f6629 --- /dev/null +++ b/src/components/dispatch-console/__tests__/personnel-actions-panel.test.tsx @@ -0,0 +1,218 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { getAllGroups } from '@/api/groups/groups'; +import { getPois } from '@/api/mapping/mapping'; +import { savePersonsStatuses } from '@/api/personnel/personnelStatuses'; +import { getAllPersonnelStaffings, getAllPersonnelStatuses } from '@/api/satuses'; +import { CustomStateDetailType, DestinationEntityType } from '@/lib/destination-helpers'; +import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; +import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; +import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store'; +import { usePersonnelActionsStore } from '@/stores/dispatch/personnel-actions-store'; + +import { PersonnelActionsPanel } from '../personnel-actions-panel'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => (typeof fallback === 'string' ? fallback : key), + }), +})); + +jest.mock('@/api/groups/groups', () => ({ getAllGroups: jest.fn() })); +jest.mock('@/api/mapping/mapping', () => ({ getPois: jest.fn() })); +jest.mock('@/api/satuses', () => ({ getAllPersonnelStatuses: jest.fn(), getAllPersonnelStaffings: jest.fn() })); +jest.mock('@/api/personnel/personnelStatuses', () => ({ savePersonsStatuses: jest.fn() })); +jest.mock('@/api/personnel/personnelStaffing', () => ({ savePersonsStaffings: jest.fn() })); + +jest.mock('@/components/calls/udf-fields-renderer', () => ({ + UdfFieldsRenderer: () => null, +})); + +const mockCalls: CallResultData[] = [ + { CallId: 'A', Number: '26-A', Name: 'Structure Fire', State: 0 } as CallResultData, + { CallId: 'B', Number: '26-B', Name: 'Medical Aid', State: 0 } as CallResultData, +]; +const mockFetchCalls = jest.fn(); +jest.mock('@/stores/calls/store', () => ({ + useCallsStore: (selector: (state: { calls: CallResultData[]; fetchCalls: () => void }) => unknown) => selector({ calls: mockCalls, fetchCalls: mockFetchCalls }), +})); + +jest.mock('@/components/ui/actionsheet', () => { + const { View } = require('react-native'); + return { + Actionsheet: ({ children, isOpen }: any) => (isOpen ? {children} : null), + ActionsheetBackdrop: () => null, + ActionsheetContent: ({ children }: any) => {children}, + ActionsheetDragIndicator: () => null, + ActionsheetDragIndicatorWrapper: ({ children }: any) => children, + }; +}); + +jest.mock('@/components/ui/button', () => { + const { Text, TouchableOpacity } = require('react-native'); + return { + Button: ({ children, onPress, isDisabled }: any) => ( + + {children} + + ), + ButtonText: ({ children }: any) => {children}, + ButtonSpinner: () => null, + }; +}); + +jest.mock('@/components/ui/icon', () => ({ + Icon: () => null, +})); + +jest.mock('@/components/ui/spinner', () => ({ + Spinner: () => null, +})); + +const mockSavePersonsStatuses = savePersonsStatuses as jest.MockedFunction; + +const status = (Id: number, Text: string, Detail: number): StatusesResultData => ({ Id, Type: 1, StateId: 0, Text, BColor: '#123456', Color: '', Gps: false, Note: 0, Detail }) as StatusesResultData; +const statuses = [status(1, 'Not Responding', CustomStateDetailType.None), status(2, 'Responding', CustomStateDetailType.CallsAndStations), status(3, 'On Scene', CustomStateDetailType.Calls)]; + +const person = (UserId: string, StatusDestinationId = ''): PersonnelInfoResultData => ({ UserId, FirstName: 'Pat', LastName: UserId, GroupName: 'Station 1', StatusDestinationId }) as PersonnelInfoResultData; + +const selectedCallInStore = () => usePersonnelActionsStore.getState().statusSelectedCall?.CallId ?? null; + +const pickStatus = async (text: string) => { + fireEvent.press(screen.getByText('dispatch.personnel_actions.select_status')); + fireEvent.press(await screen.findByText(text)); +}; + +describe('PersonnelActionsPanel destination defaults', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getAllPersonnelStatuses as jest.Mock).mockResolvedValue({ Data: statuses }); + (getAllPersonnelStaffings as jest.Mock).mockResolvedValue({ Data: [] }); + (getAllGroups as jest.Mock).mockResolvedValue({ Data: [] }); + (getPois as jest.Mock).mockResolvedValue({ Data: [] }); + mockSavePersonsStatuses.mockResolvedValue({} as any); + usePersonnelActionsStore.getState().reset(); + useDispatchConsoleStore.setState({ selectedCallId: null, isCallFilterActive: false }); + }); + + it("uses the console's selected call and keeps it sticky across submits", async () => { + useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true }); + const target = person('p1'); + act(() => { + usePersonnelActionsStore.getState().openActions(target); + }); + render(); + await waitFor(() => expect(usePersonnelActionsStore.getState().isLoadingOptions).toBe(false)); + await waitFor(() => expect(selectedCallInStore()).toBe('B')); + + await pickStatus('Responding'); + expect(screen.getByText('#26-B - Medical Aid')).toBeTruthy(); + fireEvent.press(screen.getByTestId('update-button')); + await waitFor(() => expect(mockSavePersonsStatuses).toHaveBeenCalledTimes(1)); + expect(mockSavePersonsStatuses.mock.calls[0][0]).toMatchObject({ UserIds: ['p1'], Type: '2', RespondingTo: 'B', RespondingToType: DestinationEntityType.Call }); + + await waitFor(() => expect(usePersonnelActionsStore.getState().selectedStatus).toBeNull()); + await pickStatus('On Scene'); + fireEvent.press(screen.getByTestId('update-button')); + await waitFor(() => expect(mockSavePersonsStatuses).toHaveBeenCalledTimes(2)); + expect(mockSavePersonsStatuses.mock.calls[1][0]).toMatchObject({ Type: '3', RespondingTo: 'B', RespondingToType: DestinationEntityType.Call }); + }); + + it("prefers the person's current destination call and re-initialises for the next person", async () => { + useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true }); + const first = person('p1', 'A'); + const second = person('p2'); + act(() => { + usePersonnelActionsStore.getState().openActions(first); + }); + const view = render(); + await waitFor(() => expect(selectedCallInStore()).toBe('A')); + + view.rerender(); + act(() => { + usePersonnelActionsStore.getState().openActions(second); + }); + await waitFor(() => expect(selectedCallInStore()).toBe('B')); + }); + + it("waits for the next person's options before settling on their station", async () => { + // The first person's load fails, so their panel finishes loading with no stations at all. + (getAllGroups as jest.Mock).mockRejectedValueOnce(new Error('offline')).mockResolvedValue({ Data: [{ GroupId: 'g-5', Name: 'Station 5', GroupType: 'Station' }] }); + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); + const first = person('p1'); + const second = person('p2', 'g-5'); + act(() => { + usePersonnelActionsStore.getState().openActions(first); + }); + const view = render(); + await waitFor(() => expect(usePersonnelActionsStore.getState().destinationInitializedSessionId).toBe(usePersonnelActionsStore.getState().actionsSessionId)); + + view.rerender(); + act(() => { + usePersonnelActionsStore.getState().openActions(second); + }); + + await waitFor(() => expect(usePersonnelActionsStore.getState().statusSelectedStation?.GroupId).toBe('g-5')); + consoleError.mockRestore(); + }); + + describe('destination sheet after a status that needs a destination', () => { + const openPanelFor = async (target: PersonnelInfoResultData) => { + act(() => { + usePersonnelActionsStore.getState().openActions(target); + }); + const view = render(); + await waitFor(() => expect(usePersonnelActionsStore.getState().destinationInitializedSessionId).toBe(usePersonnelActionsStore.getState().actionsSessionId)); + return view; + }; + + afterEach(() => { + jest.useRealTimers(); + }); + + it('opens a beat after the status is chosen', async () => { + await openPanelFor(person('p1')); + jest.useFakeTimers(); + + await pickStatus('Responding'); + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(mockFetchCalls).toHaveBeenCalledTimes(1); + }); + + it('does not open for the next person when the dispatcher switches within that beat', async () => { + const view = await openPanelFor(person('p1')); + jest.useFakeTimers(); + + await pickStatus('Responding'); + const second = person('p2'); + view.rerender(); + act(() => { + usePersonnelActionsStore.getState().openActions(second); + }); + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(mockFetchCalls).not.toHaveBeenCalled(); + }); + }); + + it('does not send a leftover call with a status that does not support calls', async () => { + const target = person('p1', 'A'); + act(() => { + usePersonnelActionsStore.getState().openActions(target); + }); + render(); + await waitFor(() => expect(selectedCallInStore()).toBe('A')); + + await pickStatus('Not Responding'); + fireEvent.press(screen.getByTestId('update-button')); + await waitFor(() => expect(mockSavePersonsStatuses).toHaveBeenCalledTimes(1)); + expect(mockSavePersonsStatuses.mock.calls[0][0]).toMatchObject({ Type: '1', RespondingTo: '', RespondingToType: null }); + }); +}); diff --git a/src/components/dispatch-console/__tests__/unit-actions-panel.test.tsx b/src/components/dispatch-console/__tests__/unit-actions-panel.test.tsx new file mode 100644 index 00000000..05cb6fde --- /dev/null +++ b/src/components/dispatch-console/__tests__/unit-actions-panel.test.tsx @@ -0,0 +1,221 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { getSetUnitStatusData } from '@/api/dispatch/dispatch'; +import { saveUnitStatus } from '@/api/units/unitStatuses'; +import { CustomStateDetailType, DestinationEntityType } from '@/lib/destination-helpers'; +import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; +import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; +import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store'; +import { useUnitActionsStore } from '@/stores/dispatch/unit-actions-store'; + +import { UnitActionsPanel } from '../unit-actions-panel'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => (typeof fallback === 'string' ? fallback : key), + }), +})); + +jest.mock('@/api/dispatch/dispatch', () => ({ + getSetUnitStatusData: jest.fn(), +})); + +jest.mock('@/api/units/unitStatuses', () => ({ + saveUnitStatus: jest.fn(), +})); + +jest.mock('@/components/calls/udf-fields-renderer', () => ({ + UdfFieldsRenderer: () => null, +})); + +// Calls store: a stable array so the panel's memoised active-call list does not churn between renders +const mockCalls: CallResultData[] = [ + { CallId: 'A', Number: '26-A', Name: 'Structure Fire', State: 0 } as CallResultData, + { CallId: 'B', Number: '26-B', Name: 'Medical Aid', State: 0 } as CallResultData, + { CallId: 'CLOSED', Number: '26-C', Name: 'Old Call', State: 4 } as CallResultData, +]; +const mockFetchCalls = jest.fn(); +jest.mock('@/stores/calls/store', () => ({ + useCallsStore: (selector: (state: { calls: CallResultData[]; fetchCalls: () => void }) => unknown) => selector({ calls: mockCalls, fetchCalls: mockFetchCalls }), +})); + +jest.mock('@/stores/units/store', () => ({ + useUnitsStore: { + getState: () => ({ unitStatuses: [], fetchUnits: jest.fn() }), + }, +})); + +jest.mock('@/components/ui/actionsheet', () => { + const { View } = require('react-native'); + return { + Actionsheet: ({ children, isOpen }: any) => (isOpen ? {children} : null), + ActionsheetBackdrop: () => null, + ActionsheetContent: ({ children }: any) => {children}, + ActionsheetDragIndicator: () => null, + ActionsheetDragIndicatorWrapper: ({ children }: any) => children, + }; +}); + +jest.mock('@/components/ui/button', () => { + const { Text, TouchableOpacity } = require('react-native'); + return { + Button: ({ children, onPress, isDisabled }: any) => ( + + {children} + + ), + ButtonText: ({ children }: any) => {children}, + ButtonSpinner: () => null, + }; +}); + +jest.mock('@/components/ui/icon', () => ({ + Icon: () => null, +})); + +jest.mock('@/components/ui/spinner', () => ({ + Spinner: () => null, +})); + +const mockGetSetUnitStatusData = getSetUnitStatusData as jest.MockedFunction; +const mockSaveUnitStatus = saveUnitStatus as jest.MockedFunction; + +const status = (Id: number, Text: string, Detail: number): StatusesResultData => ({ Id, Type: 3, StateId: 0, Text, BColor: '#123456', Color: '', Gps: false, Note: 0, Detail }) as StatusesResultData; +const statuses = [status(0, 'Available', CustomStateDetailType.Stations), status(3, 'Responding', CustomStateDetailType.Calls), status(6, 'On Scene', CustomStateDetailType.Calls), status(8, 'Out of Service', CustomStateDetailType.None)]; + +const unit = (UnitId: string, CurrentDestinationId = ''): UnitInfoResultData => ({ UnitId, Name: `Unit ${UnitId}`, Type: 'Engine', CustomStatusSetId: '', CurrentDestinationId }) as UnitInfoResultData; + +const selectedCallInStore = () => useUnitActionsStore.getState().statusSelectedCall?.CallId ?? null; + +const renderOpenPanel = async (target: UnitInfoResultData) => { + act(() => { + useUnitActionsStore.getState().openActions(target); + }); + const view = render(); + await waitFor(() => expect(useUnitActionsStore.getState().isLoadingOptions).toBe(false)); + return view; +}; + +const pickStatus = async (text: string) => { + fireEvent.press(screen.getByText('dispatch.unit_actions_panel.select_status')); + fireEvent.press(await screen.findByText(text)); +}; + +describe('UnitActionsPanel destination defaults', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetSetUnitStatusData.mockResolvedValue({ Data: { Statuses: statuses, Calls: [], Stations: [], DestinationPois: [] } } as any); + mockSaveUnitStatus.mockResolvedValue({} as any); + useUnitActionsStore.getState().reset(); + useDispatchConsoleStore.setState({ selectedCallId: null, isCallFilterActive: false }); + }); + + it("defaults to the unit's current destination when it is an active call", async () => { + useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true }); + await renderOpenPanel(unit('u1', 'A')); + + await waitFor(() => expect(selectedCallInStore()).toBe('A')); + }); + + it("falls back to the console's selected call when the unit's destination is not an active call", async () => { + useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true }); + await renderOpenPanel(unit('u1', 'CLOSED')); + + await waitFor(() => expect(selectedCallInStore()).toBe('B')); + + // The panel shows the defaulted call once a status that accepts calls is picked + await pickStatus('Responding'); + expect(screen.getByText('#26-B - Medical Aid')).toBeTruthy(); + }); + + it('defaults to no destination when neither the unit destination nor a selected call is active', async () => { + await renderOpenPanel(unit('u1', 'CLOSED')); + + await waitFor(() => expect(useUnitActionsStore.getState().destinationInitializedSessionId).toBe(useUnitActionsStore.getState().actionsSessionId)); + expect(useUnitActionsStore.getState().statusDestinationType).toBe('none'); + }); + + it('shows no destination for a status that does not support the defaulted call', async () => { + useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true }); + await renderOpenPanel(unit('u1')); + await waitFor(() => expect(selectedCallInStore()).toBe('B')); + + await pickStatus('Available'); + await waitFor(() => expect(screen.getAllByText('dispatch.unit_actions_panel.no_destination').length).toBeGreaterThan(0)); + expect(screen.queryByText('#26-B - Medical Aid')).toBeNull(); + }); + + it('keeps the destination after a successful submit and does not re-apply the default', async () => { + await renderOpenPanel(unit('u1', 'A')); + await waitFor(() => expect(selectedCallInStore()).toBe('A')); + + await pickStatus('Responding'); + fireEvent.press(screen.getByTestId('update-status-button')); + await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalledTimes(1)); + expect(mockSaveUnitStatus.mock.calls[0][0]).toMatchObject({ Id: 'u1', Type: '3', RespondingTo: 'A', RespondingToType: DestinationEntityType.Call }); + + // Follow-up status for the same unit still goes to call A + await waitFor(() => expect(useUnitActionsStore.getState().selectedStatus).toBeNull()); + expect(selectedCallInStore()).toBe('A'); + await pickStatus('On Scene'); + expect(screen.getByText('#26-A - Structure Fire')).toBeTruthy(); + fireEvent.press(screen.getByTestId('update-status-button')); + await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalledTimes(2)); + expect(mockSaveUnitStatus.mock.calls[1][0]).toMatchObject({ Id: 'u1', Type: '6', RespondingTo: 'A', RespondingToType: DestinationEntityType.Call }); + }); + + it('re-initialises the default destination when the selected unit changes', async () => { + useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true }); + const first = unit('u1', 'A'); + const second = unit('u2'); + const view = await renderOpenPanel(first); + await waitFor(() => expect(selectedCallInStore()).toBe('A')); + + // The dashboard re-renders the panel with the new unit before its parent re-opens the store for it + view.rerender(); + act(() => { + useUnitActionsStore.getState().openActions(second); + }); + + await waitFor(() => expect(selectedCallInStore()).toBe('B')); + expect(useUnitActionsStore.getState().selectedUnit?.UnitId).toBe('u2'); + }); + + it('does not re-apply the default when the panel remounts within the same open session', async () => { + useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true }); + const target = unit('u1', 'A'); + const view = await renderOpenPanel(target); + await waitFor(() => expect(selectedCallInStore()).toBe('A')); + + // The dispatcher changes the destination, then switches tabs away and back (unmount/remount) + act(() => { + useUnitActionsStore.getState().setStatusDestinationType('none'); + }); + view.unmount(); + render(); + await waitFor(() => expect(useUnitActionsStore.getState().isLoadingOptions).toBe(false)); + + expect(useUnitActionsStore.getState().statusDestinationType).toBe('none'); + }); + + it('shows and sends the explicit call context even for a status whose Detail lists no destinations', async () => { + const target = unit('u1', 'A'); + act(() => { + useUnitActionsStore.getState().openActions(target, { callContext: mockCalls[1] }); + }); + render(); + await waitFor(() => expect(useUnitActionsStore.getState().isLoadingOptions).toBe(false)); + + // The explicit context wins over the unit's current destination + expect(selectedCallInStore()).toBe('B'); + + await pickStatus('Out of Service'); + expect(screen.getByText('#26-B - Medical Aid')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('update-status-button')); + await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalledTimes(1)); + expect(mockSaveUnitStatus.mock.calls[0][0]).toMatchObject({ Id: 'u1', Type: '8', RespondingTo: 'B', RespondingToType: DestinationEntityType.Call }); + }); +}); diff --git a/src/components/dispatch-console/activity-log-panel.tsx b/src/components/dispatch-console/activity-log-panel.tsx index 1f8b032b..46e69ac0 100644 --- a/src/components/dispatch-console/activity-log-panel.tsx +++ b/src/components/dispatch-console/activity-log-panel.tsx @@ -24,6 +24,7 @@ import { usePersonnelActionsStore } from '@/stores/dispatch/personnel-actions-st import { useUnitActionsStore } from '@/stores/dispatch/unit-actions-store'; import { useWeatherAlertsStore } from '@/stores/weatherAlerts/store'; +import { ActivityLinkLegend, ActivityLinkMarker } from '../calls/activity-link-marker'; import { CheckInBottomSheet } from '../checkIn/check-in-bottom-sheet'; import { CheckInTimerCard } from '../checkIn/check-in-timer-card'; import { PanelHeader } from './panel-header'; @@ -188,6 +189,7 @@ const CallActivityItem: React.FC<{ activity: DispatchedEventResultData }> = Reac ) : null} + {activity.Note ? ( {activity.Note} @@ -332,6 +334,12 @@ const ActivityLogPanelComponent: React.FC = ({ // Track previous call selection to avoid unnecessary tab switches const prevSelectedCallIdRef = useRef(undefined); + // Actions-panel open sessions — a "+" set-status-for-call open (with a call context) must surface the + // actions tab even when the unit/person was already selected and the selection itself did not change. + const unitActionsSessionId = useUnitActionsStore((s) => s.actionsSessionId); + const personnelActionsSessionId = usePersonnelActionsStore((s) => s.actionsSessionId); + const prevActionsSessionIdsRef = useRef({ unit: unitActionsSessionId, personnel: personnelActionsSessionId }); + // Reset to activity tab if check-ins tab is active but call filter is cleared useEffect(() => { if (!isCallFilterActive && activeTab === 'checkins') { @@ -364,7 +372,11 @@ const ActivityLogPanelComponent: React.FC = ({ prevSelectedPersonnelIdRef.current = currentId; if (selectedPersonnel && currentId) { - openPersonnelActions(selectedPersonnel); + // Don't re-open (and wipe) a panel already opened for this person, e.g. by "+" with a call context + const personnelActions = usePersonnelActionsStore.getState(); + if (!personnelActions.isActionsOpen || personnelActions.selectedPersonnel?.UserId !== currentId) { + openPersonnelActions(selectedPersonnel); + } setActiveTab('actions'); // Automatically switch to actions tab } else if (prevId && !currentId) { // Only close if we had a selection before and now we don't @@ -383,7 +395,11 @@ const ActivityLogPanelComponent: React.FC = ({ prevSelectedUnitIdRef.current = currentId; if (selectedUnit && currentId) { - openUnitActions(selectedUnit); + // Don't re-open (and wipe) a panel already opened for this unit, e.g. by "+" with a call context + const unitActions = useUnitActionsStore.getState(); + if (!unitActions.isActionsOpen || unitActions.selectedUnit?.UnitId !== currentId) { + openUnitActions(selectedUnit); + } setActiveTab('actions'); // Automatically switch to actions tab } else if (prevId && !currentId) { // Only close if we had a selection before and now we don't @@ -392,6 +408,21 @@ const ActivityLogPanelComponent: React.FC = ({ } }, [selectedUnit, selectedUnitId, openUnitActions, closeUnitActions]); + // Surface the actions panel when it is opened from an explicit call context ("+" set-status-for-call) + useEffect(() => { + const prev = prevActionsSessionIdsRef.current; + if (prev.unit === unitActionsSessionId && prev.personnel === personnelActionsSessionId) return; + prevActionsSessionIdsRef.current = { unit: unitActionsSessionId, personnel: personnelActionsSessionId }; + + const openedForCall = (prev.unit !== unitActionsSessionId && !!useUnitActionsStore.getState().callContext) || (prev.personnel !== personnelActionsSessionId && !!usePersonnelActionsStore.getState().callContext); + if (!openedForCall) return; + + setActiveTab('actions'); + if (isCollapsed) { + setCardCollapsed('activity-log', false); + } + }, [unitActionsSessionId, personnelActionsSessionId, isCollapsed, setCardCollapsed]); + // Filter entries when call filter is active const filteredEntries = useMemo( () => (isCallFilterActive && selectedCallId ? entries.filter((entry) => entry.metadata?.callId === selectedCallId || entry.type === 'system') : entries), @@ -496,6 +527,7 @@ const ActivityLogPanelComponent: React.FC = ({ maxToRenderPerBatch={15} windowSize={7} removeClippedSubviews={Platform.OS !== 'web'} + ListFooterComponent={} /> ); } diff --git a/src/components/dispatch-console/personnel-actions-panel.tsx b/src/components/dispatch-console/personnel-actions-panel.tsx index 2208d1f9..036a1d1b 100644 --- a/src/components/dispatch-console/personnel-actions-panel.tsx +++ b/src/components/dispatch-console/personnel-actions-panel.tsx @@ -15,7 +15,7 @@ import { Icon } from '@/components/ui/icon'; import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; -import { type DestinationTab, getDefaultDestinationTab, getDestinationCapabilities } from '@/lib/destination-helpers'; +import { type DestinationTab, getDefaultDestinationTab, getEffectiveDestinationType, getStatusDestinationCapabilities, resolveDefaultDestinationCall } from '@/lib/destination-helpers'; import { getPoiSelectionLabel } from '@/lib/poi-display'; import { invertColor, isCallActive } from '@/lib/utils'; import { type CallResultData } from '@/models/v4/calls/callResultData'; @@ -24,6 +24,7 @@ import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; import { useCallsStore } from '@/stores/calls/store'; +import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store'; import { usePersonnelActionsStore } from '@/stores/dispatch/personnel-actions-store'; interface PersonnelActionsPanelProps { @@ -115,6 +116,8 @@ export const PersonnelActionsPanel: React.FC = ({ pe const [isStatusSheetOpen, setIsStatusSheetOpen] = useState(false); const [isStaffingSheetOpen, setIsStaffingSheetOpen] = useState(false); const [isDestinationSheetOpen, setIsDestinationSheetOpen] = useState(false); + // The destination sheet opens a beat after the status sheet closes. + const destinationSheetTimerRef = useRef | null>(null); const [destinationTab, setDestinationTab] = useState('calls'); const [isAdditionalFieldsExpanded, setIsAdditionalFieldsExpanded] = useState(false); @@ -122,9 +125,19 @@ export const PersonnelActionsPanel: React.FC = ({ pe const [localSelectedStatus, setLocalSelectedStatus] = useState(null); const [localSelectedStaffing, setLocalSelectedStaffing] = useState(null); + // The console's selected call — a default destination when the person's own destination is not an active call + const selectedCallId = useDispatchConsoleStore((state) => state.selectedCallId); + + // Whether the destination options (stations/POIs) have finished loading for the given person + const [optionsLoadedForUserId, setOptionsLoadedForUserId] = useState(null); + // Store state const { selectedPersonnel: storeSelectedPersonnel, + callContext, + actionsSessionId, + destinationInitializedSessionId, + markDestinationInitialized, selectedStatus: storeSelectedStatus, statusDestinationType, statusSelectedCall, @@ -194,12 +207,30 @@ export const PersonnelActionsPanel: React.FC = ({ pe // Use prop if available, fallback to store const selectedPersonnel = personnelProp ?? storeSelectedPersonnel; + // A pending open belongs to the session it was chosen in: it dies with the panel, and with a switch to + // another person or a new session, so it never opens the sheet over what the dispatcher is now doing. + useEffect( + () => () => { + if (destinationSheetTimerRef.current) { + clearTimeout(destinationSheetTimerRef.current); + destinationSheetTimerRef.current = null; + } + }, + [actionsSessionId, selectedPersonnel?.UserId] + ); + // Load options when panel opens useEffect(() => { + if (!selectedPersonnel) return; + + let cancelled = false; + const loadOptions = async () => { setIsLoadingOptions(true); try { const [statusesResult, staffingsResult, groupsResult, poisResult] = await Promise.all([getAllPersonnelStatuses(), getAllPersonnelStaffings(), getAllGroups(), getPois({ destinationOnly: true })]); + // A newer load superseded this one (or the panel unmounted) — don't apply stale options. + if (cancelled) return; if (statusesResult?.Data) { setAvailableStatuses(statusesResult.Data); @@ -217,77 +248,82 @@ export const PersonnelActionsPanel: React.FC = ({ pe } catch (error) { console.error('Failed to load personnel action options:', error); } finally { - setIsLoadingOptions(false); + if (!cancelled) { + setIsLoadingOptions(false); + setOptionsLoadedForUserId(selectedPersonnel.UserId); + } } }; - if (selectedPersonnel) { - loadOptions(); - } + loadOptions(); + + return () => { + cancelled = true; + }; }, [selectedPersonnel, setAvailableStatuses, setAvailableStaffings, setAvailableStations, setAvailablePois, setIsLoadingOptions]); + // Active calls for destination selection and default-destination resolution + const activeCalls = useMemo(() => { + return calls.filter((c) => isCallActive(c.State)); + }, [calls]); + // Update available calls from calls store useEffect(() => { - const activeCalls = calls.filter((c) => isCallActive(c.State)); setAvailableCalls(activeCalls); - }, [calls, setAvailableCalls]); + }, [activeCalls, setAvailableCalls]); - // Track the last personnel ID we initialized destination for - const lastInitializedPersonnelIdRef = useRef(null); - - // Initialize destination from selected personnel's current destination (only once per personnel) + // Apply the default destination once per open of the actions panel (openActions starts a new session: + // a personnel selection, a re-selection or a "+" set-status-for-call). A successful submit does not start + // a new session, so the destination stays sticky for follow-up statuses of the same person. The applied + // session lives in the store so remounting the panel (e.g. switching tabs) does not re-apply it. useEffect(() => { - if (!selectedPersonnel) { - lastInitializedPersonnelIdRef.current = null; - return; - } - - // Only initialize once per personnel - skip if we already initialized for this person - if (lastInitializedPersonnelIdRef.current === selectedPersonnel.UserId) { + if (!selectedPersonnel) return; + // Wait until the store has been opened for this person (the parent opens it after selection changes). + if (storeSelectedPersonnel?.UserId !== selectedPersonnel.UserId) return; + if (destinationInitializedSessionId === actionsSessionId) return; + + // (a) explicit call context, (b) the person's current destination if it is an active call, (c) the console's selected call + const defaultCall = resolveDefaultDestinationCall({ + callContext, + currentDestinationId: selectedPersonnel.StatusDestinationId, + selectedCallId, + activeCalls, + }); + if (defaultCall) { + setStatusSelectedCall(defaultCall); + markDestinationInitialized(actionsSessionId); return; } - // If no destination set, just mark as initialized - if (!selectedPersonnel.StatusDestinationId) { - lastInitializedPersonnelIdRef.current = selectedPersonnel.UserId; - return; - } + // Stations and POIs come from the options load; wait for it before settling on a default. + if (optionsLoadedForUserId !== selectedPersonnel.UserId) return; const destinationId = selectedPersonnel.StatusDestinationId; + const matchingStation = destinationId ? availableStations.find((s) => s.GroupId === destinationId) : undefined; + const matchingPoi = destinationId && !matchingStation ? availablePois.find((poi) => poi.PoiId.toString() === destinationId) : undefined; - // Check if the destination is a call (check available calls) - const matchingCall = calls.find((c) => c.CallId === destinationId); - if (matchingCall) { - setStatusDestinationType('call'); - setStatusSelectedCall(matchingCall); - setStatusSelectedStation(null); - lastInitializedPersonnelIdRef.current = selectedPersonnel.UserId; - return; - } - - // Check if the destination is a station (check available stations) - const matchingStation = availableStations.find((s) => s.GroupId === destinationId); if (matchingStation) { - setStatusDestinationType('station'); setStatusSelectedStation(matchingStation); - setStatusSelectedCall(null); - lastInitializedPersonnelIdRef.current = selectedPersonnel.UserId; - return; - } - - const matchingPoi = availablePois.find((poi) => poi.PoiId.toString() === destinationId); - if (matchingPoi) { - setStatusDestinationType('poi'); + } else if (matchingPoi) { setStatusSelectedPoi(matchingPoi); - lastInitializedPersonnelIdRef.current = selectedPersonnel.UserId; - return; } - - // If we couldn't match but have data loaded, mark as initialized anyway - if (calls.length > 0 || availableStations.length > 0 || availablePois.length > 0) { - lastInitializedPersonnelIdRef.current = selectedPersonnel.UserId; - } - }, [selectedPersonnel, calls, availableStations, availablePois, setStatusDestinationType, setStatusSelectedCall, setStatusSelectedStation, setStatusSelectedPoi]); + markDestinationInitialized(actionsSessionId); + }, [ + selectedPersonnel, + storeSelectedPersonnel, + callContext, + actionsSessionId, + destinationInitializedSessionId, + selectedCallId, + activeCalls, + optionsLoadedForUserId, + availableStations, + availablePois, + markDestinationInitialized, + setStatusSelectedCall, + setStatusSelectedStation, + setStatusSelectedPoi, + ]); const handleSubmitStatus = useCallback(async () => { // Pass current personnel and status directly to avoid state sync issues @@ -313,23 +349,40 @@ export const PersonnelActionsPanel: React.FC = ({ pe } }, [storeSubmitStaffing, selectedPersonnel, localSelectedStaffing, onStaffingUpdated]); + // An explicit call context ("+" set-status-for-call) may carry its call with any status + const hasCallContext = !!callContext; + + const destinationSelection = useMemo( + () => ({ + selectedDestinationType: statusDestinationType, + selectedCall: statusSelectedCall, + selectedStation: statusSelectedStation, + selectedPoi: statusSelectedPoi, + }), + [statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi] + ); + + // The destination that will actually be sent with the selected status (a destination the status + // does not support is dropped), so the panel shows exactly what will be saved. + const effectiveDestinationType = useMemo(() => getEffectiveDestinationType(destinationSelection, selectedStatus?.Detail, hasCallContext), [destinationSelection, selectedStatus, hasCallContext]); + // Get destination display text const getDestinationDisplay = useMemo(() => { - if (statusDestinationType === 'call' && statusSelectedCall) { + if (effectiveDestinationType === 'call' && statusSelectedCall) { return `#${statusSelectedCall.Number} - ${statusSelectedCall.Name}`; } - if (statusDestinationType === 'station' && statusSelectedStation) { + if (effectiveDestinationType === 'station' && statusSelectedStation) { return statusSelectedStation.Name; } - if (statusDestinationType === 'poi' && statusSelectedPoi) { + if (effectiveDestinationType === 'poi' && statusSelectedPoi) { return getPoiSelectionLabel(statusSelectedPoi); } return t('dispatch.personnel_actions.no_destination'); - }, [statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, t]); + }, [effectiveDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, t]); const destinationConfig = useMemo(() => { - return getDestinationCapabilities(selectedStatus?.Detail); - }, [selectedStatus]); + return getStatusDestinationCapabilities(selectedStatus?.Detail, hasCallContext); + }, [selectedStatus, hasCallContext]); // Check note requirement based on Note field // Note: 0 = No note, 1 = Optional, 2 = Required @@ -365,22 +418,11 @@ export const PersonnelActionsPanel: React.FC = ({ pe return true; }, [selectedStaffing, staffingNote]); - // Active calls for destination selection - const activeCalls = useMemo(() => { - const filtered = calls.filter((c) => isCallActive(c.State)); - console.log('[PersonnelActionsPanel] Active calls:', { - totalCalls: calls.length, - activeCalls: filtered.length, - allStates: calls.map((c) => c.State), - }); - return filtered; - }, [calls]); - useEffect(() => { if (selectedStatus) { - setDestinationTab(getDefaultDestinationTab(selectedStatus.Detail)); + setDestinationTab(hasCallContext ? 'calls' : getDefaultDestinationTab(selectedStatus.Detail)); } - }, [selectedStatus]); + }, [selectedStatus, hasCallContext]); // Refresh calls when destination sheet opens useEffect(() => { @@ -393,9 +435,15 @@ export const PersonnelActionsPanel: React.FC = ({ pe const handleStatusSelect = (status: StatusesResultData) => { setSelectedStatus(status); setIsStatusSheetOpen(false); - // If status requires destination, open destination sheet - if (getDestinationCapabilities(status.Detail).supportsDestination) { - setTimeout(() => setIsDestinationSheetOpen(true), 300); + // If the status takes a destination and none applies yet, open the destination sheet. A preset or + // sticky destination the status supports (e.g. the call) is kept and shown instead. + const supportsDestination = getStatusDestinationCapabilities(status.Detail, hasCallContext).supportsDestination; + if (supportsDestination && getEffectiveDestinationType(destinationSelection, status.Detail, hasCallContext) === 'none') { + if (destinationSheetTimerRef.current) clearTimeout(destinationSheetTimerRef.current); + destinationSheetTimerRef.current = setTimeout(() => { + destinationSheetTimerRef.current = null; + setIsDestinationSheetOpen(true); + }, 300); } }; @@ -408,10 +456,8 @@ export const PersonnelActionsPanel: React.FC = ({ pe // Handle destination selection const handleDestinationSelect = (type: 'none' | 'call' | 'station' | 'poi', item?: CallResultData | GroupResultData | PoiResultData) => { if (type === 'none') { + // Clears the call, station and POI selections too setStatusDestinationType('none'); - setStatusSelectedCall(null); - setStatusSelectedStation(null); - setStatusSelectedPoi(null); } else if (type === 'call' && item) { setStatusSelectedCall(item as CallResultData); } else if (type === 'station' && item) { @@ -484,7 +530,7 @@ export const PersonnelActionsPanel: React.FC = ({ pe setIsDestinationSheetOpen(true)}> - + {t('dispatch.personnel_actions.destination')} @@ -633,7 +679,7 @@ export const PersonnelActionsPanel: React.FC = ({ pe {t('dispatch.personnel_actions.destination')} {/* No Destination Option */} - handleDestinationSelect('none')} label={t('dispatch.personnel_actions.no_destination')} /> + handleDestinationSelect('none')} label={t('dispatch.personnel_actions.no_destination')} /> {/* Tabs for Calls and Stations */} {destinationConfig.showCalls || destinationConfig.showStations || destinationConfig.showPois ? ( @@ -673,7 +719,7 @@ export const PersonnelActionsPanel: React.FC = ({ pe key={call.CallId} type="call" item={call} - isSelected={statusDestinationType === 'call' && statusSelectedCall?.CallId === call.CallId} + isSelected={effectiveDestinationType === 'call' && statusSelectedCall?.CallId === call.CallId} onSelect={() => handleDestinationSelect('call', call)} /> )) @@ -692,7 +738,7 @@ export const PersonnelActionsPanel: React.FC = ({ pe key={station.GroupId} type="station" item={station} - isSelected={statusDestinationType === 'station' && statusSelectedStation?.GroupId === station.GroupId} + isSelected={effectiveDestinationType === 'station' && statusSelectedStation?.GroupId === station.GroupId} onSelect={() => handleDestinationSelect('station', station)} /> )) @@ -710,7 +756,7 @@ export const PersonnelActionsPanel: React.FC = ({ pe key={poi.PoiId} type="poi" item={poi} - isSelected={statusDestinationType === 'poi' && statusSelectedPoi?.PoiId === poi.PoiId} + isSelected={effectiveDestinationType === 'poi' && statusSelectedPoi?.PoiId === poi.PoiId} onSelect={() => handleDestinationSelect('poi', poi)} /> )) diff --git a/src/components/dispatch-console/unit-actions-panel.tsx b/src/components/dispatch-console/unit-actions-panel.tsx index 101c355b..f588746d 100644 --- a/src/components/dispatch-console/unit-actions-panel.tsx +++ b/src/components/dispatch-console/unit-actions-panel.tsx @@ -13,7 +13,7 @@ import { Icon } from '@/components/ui/icon'; import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; -import { type DestinationTab, getDefaultDestinationTab, getDestinationCapabilities } from '@/lib/destination-helpers'; +import { type DestinationTab, getDefaultDestinationTab, getEffectiveDestinationType, getStatusDestinationCapabilities, resolveDefaultDestinationCall } from '@/lib/destination-helpers'; import { getPoiSelectionLabel } from '@/lib/poi-display'; import { resolveUnitStatusOptions } from '@/lib/unit-status-helpers'; import { invertColor, isCallActive } from '@/lib/utils'; @@ -23,6 +23,7 @@ import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; import { useCallsStore } from '@/stores/calls/store'; +import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store'; import { useUnitActionsStore } from '@/stores/dispatch/unit-actions-store'; import { useUnitsStore } from '@/stores/units/store'; @@ -118,15 +119,27 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr // Local state for action sheets const [isStatusSheetOpen, setIsStatusSheetOpen] = useState(false); const [isDestinationSheetOpen, setIsDestinationSheetOpen] = useState(false); + // The destination sheet opens a beat after the status sheet closes. + const destinationSheetTimerRef = useRef | null>(null); const [isAdditionalFieldsExpanded, setIsAdditionalFieldsExpanded] = useState(false); const [destinationTab, setDestinationTab] = useState('calls'); // Local state for selected status (to fix synchronization issues) const [localSelectedStatus, setLocalSelectedStatus] = useState(null); + // The console's selected call — a default destination when the unit's own destination is not an active call + const selectedCallId = useDispatchConsoleStore((state) => state.selectedCallId); + + // Whether the per-unit options (stations/POIs) have finished loading for the given unit + const [optionsLoadedForUnitId, setOptionsLoadedForUnitId] = useState(null); + // Store state const { selectedUnit: storeSelectedUnit, + callContext, + actionsSessionId, + destinationInitializedSessionId, + markDestinationInitialized, selectedStatus: storeSelectedStatus, statusDestinationType, statusSelectedCall, @@ -174,10 +187,24 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr // Use prop if available, fallback to store const selectedUnit = unitProp ?? storeSelectedUnit; + // A pending open belongs to the session it was chosen in: it dies with the panel, and with a switch to + // another unit or a new session, so it never opens the sheet over what the dispatcher is now doing. + useEffect( + () => () => { + if (destinationSheetTimerRef.current) { + clearTimeout(destinationSheetTimerRef.current); + destinationSheetTimerRef.current = null; + } + }, + [actionsSessionId, selectedUnit?.UnitId] + ); + // Load options when panel opens useEffect(() => { if (!selectedUnit) return; + let cancelled = false; + const loadOptions = async () => { setIsLoadingOptions(true); try { @@ -188,6 +215,8 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr await useUnitsStore.getState().fetchUnits(); groups = useUnitsStore.getState().unitStatuses; } + // A newer unit was selected while this load was in flight — don't overwrite its options. + if (cancelled) return; const serverStatuses = (unitStatusData?.Data?.Statuses as unknown as StatusesResultData[]) || []; setAvailableStatuses(resolveUnitStatusOptions(selectedUnit, groups, serverStatuses)); setAvailableCalls(unitStatusData?.Data?.Calls || []); @@ -196,77 +225,82 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr } catch (error) { console.error('Failed to load unit action options:', error); } finally { - setIsLoadingOptions(false); + if (!cancelled) { + setIsLoadingOptions(false); + setOptionsLoadedForUnitId(selectedUnit.UnitId); + } } }; - if (selectedUnit) { - loadOptions(); - } + loadOptions(); + + return () => { + cancelled = true; + }; }, [selectedUnit, setAvailableStatuses, setAvailableCalls, setAvailableStations, setAvailablePois, setIsLoadingOptions]); + // Active calls for destination selection and default-destination resolution + const activeCalls = useMemo(() => { + return calls.filter((c) => isCallActive(c.State)); + }, [calls]); + // Update available calls from calls store useEffect(() => { - const activeCalls = calls.filter((c) => isCallActive(c.State)); setAvailableCalls(activeCalls); - }, [calls, setAvailableCalls]); + }, [activeCalls, setAvailableCalls]); - // Track the last unit ID we initialized destination for - const lastInitializedUnitIdRef = useRef(null); - - // Initialize destination from selected unit's current destination (only once per unit) + // Apply the default destination once per open of the actions panel (openActions starts a new session: + // a unit selection, a re-selection or a "+" set-status-for-call). A successful submit does not start a + // new session, so the destination stays sticky for follow-up statuses of the same unit. The applied + // session lives in the store so remounting the panel (e.g. switching tabs) does not re-apply it. useEffect(() => { - if (!selectedUnit) { - lastInitializedUnitIdRef.current = null; - return; - } - - // Only initialize once per unit - skip if we already initialized for this unit - if (lastInitializedUnitIdRef.current === selectedUnit.UnitId) { + if (!selectedUnit) return; + // Wait until the store has been opened for this unit (the parent opens it after selection changes). + if (storeSelectedUnit?.UnitId !== selectedUnit.UnitId) return; + if (destinationInitializedSessionId === actionsSessionId) return; + + // (a) explicit call context, (b) the unit's current destination if it is an active call, (c) the console's selected call + const defaultCall = resolveDefaultDestinationCall({ + callContext, + currentDestinationId: selectedUnit.CurrentDestinationId, + selectedCallId, + activeCalls, + }); + if (defaultCall) { + setStatusSelectedCall(defaultCall); + markDestinationInitialized(actionsSessionId); return; } - // If no destination set, just mark as initialized - if (!selectedUnit.CurrentDestinationId) { - lastInitializedUnitIdRef.current = selectedUnit.UnitId; - return; - } + // Stations and POIs come from the per-unit options load; wait for it before settling on a default. + if (optionsLoadedForUnitId !== selectedUnit.UnitId) return; const destinationId = selectedUnit.CurrentDestinationId; + const matchingStation = destinationId ? availableStations.find((s) => s.GroupId === destinationId) : undefined; + const matchingPoi = destinationId && !matchingStation ? availablePois.find((poi) => poi.PoiId.toString() === destinationId) : undefined; - // Check if the destination is a call (check available calls) - const matchingCall = calls.find((c) => c.CallId === destinationId); - if (matchingCall) { - setStatusDestinationType('call'); - setStatusSelectedCall(matchingCall); - setStatusSelectedStation(null); - lastInitializedUnitIdRef.current = selectedUnit.UnitId; - return; - } - - // Check if the destination is a station (check available stations) - const matchingStation = availableStations.find((s) => s.GroupId === destinationId); if (matchingStation) { - setStatusDestinationType('station'); setStatusSelectedStation(matchingStation); - setStatusSelectedCall(null); - lastInitializedUnitIdRef.current = selectedUnit.UnitId; - return; - } - - const matchingPoi = availablePois.find((poi) => poi.PoiId.toString() === destinationId); - if (matchingPoi) { - setStatusDestinationType('poi'); + } else if (matchingPoi) { setStatusSelectedPoi(matchingPoi); - lastInitializedUnitIdRef.current = selectedUnit.UnitId; - return; - } - - // If we couldn't match but have data loaded, mark as initialized anyway - if (calls.length > 0 || availableStations.length > 0 || availablePois.length > 0) { - lastInitializedUnitIdRef.current = selectedUnit.UnitId; } - }, [selectedUnit, calls, availableStations, availablePois, setStatusDestinationType, setStatusSelectedCall, setStatusSelectedStation, setStatusSelectedPoi]); + markDestinationInitialized(actionsSessionId); + }, [ + selectedUnit, + storeSelectedUnit, + callContext, + actionsSessionId, + destinationInitializedSessionId, + selectedCallId, + activeCalls, + optionsLoadedForUnitId, + availableStations, + availablePois, + markDestinationInitialized, + setStatusSelectedCall, + setStatusSelectedStation, + setStatusSelectedPoi, + ]); const handleSubmitStatus = useCallback(async () => { // Pass current unit and status directly to avoid state sync issues @@ -280,24 +314,41 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr } }, [storeSubmitStatus, selectedUnit, localSelectedStatus, onStatusUpdated]); + // An explicit call context ("+" set-status-for-call) may carry its call with any status + const hasCallContext = !!callContext; + + const destinationSelection = useMemo( + () => ({ + selectedDestinationType: statusDestinationType, + selectedCall: statusSelectedCall, + selectedStation: statusSelectedStation, + selectedPoi: statusSelectedPoi, + }), + [statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi] + ); + + // The destination that will actually be sent with the selected status (a destination the status + // does not support is dropped), so the panel shows exactly what will be saved. + const effectiveDestinationType = useMemo(() => getEffectiveDestinationType(destinationSelection, selectedStatus?.Detail, hasCallContext), [destinationSelection, selectedStatus, hasCallContext]); + // Get destination display text const getDestinationDisplay = useMemo(() => { - if (statusDestinationType === 'call' && statusSelectedCall) { + if (effectiveDestinationType === 'call' && statusSelectedCall) { return `#${statusSelectedCall.Number} - ${statusSelectedCall.Name}`; } - if (statusDestinationType === 'station' && statusSelectedStation) { + if (effectiveDestinationType === 'station' && statusSelectedStation) { return statusSelectedStation.Name; } - if (statusDestinationType === 'poi' && statusSelectedPoi) { + if (effectiveDestinationType === 'poi' && statusSelectedPoi) { return getPoiSelectionLabel(statusSelectedPoi); } return t('dispatch.unit_actions_panel.no_destination'); - }, [statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, t]); + }, [effectiveDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, t]); // Check destination type allowed based on Detail const destinationConfig = useMemo(() => { - return getDestinationCapabilities(selectedStatus?.Detail); - }, [selectedStatus]); + return getStatusDestinationCapabilities(selectedStatus?.Detail, hasCallContext); + }, [selectedStatus, hasCallContext]); // Check note requirement based on Note field // Note: 0 = No note, 1 = Optional, 2 = Required @@ -317,16 +368,11 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr return true; }, [selectedStatus, statusNote]); - // Active calls for destination selection - const activeCalls = useMemo(() => { - return calls.filter((c) => isCallActive(c.State)); - }, [calls]); - useEffect(() => { if (selectedStatus) { - setDestinationTab(getDefaultDestinationTab(selectedStatus.Detail)); + setDestinationTab(hasCallContext ? 'calls' : getDefaultDestinationTab(selectedStatus.Detail)); } - }, [selectedStatus]); + }, [selectedStatus, hasCallContext]); // Refresh calls when destination sheet opens useEffect(() => { @@ -339,19 +385,23 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr const handleStatusSelect = (status: StatusesResultData) => { setSelectedStatus(status); setIsStatusSheetOpen(false); - // If status requires destination, open destination sheet - if (getDestinationCapabilities(status.Detail).supportsDestination) { - setTimeout(() => setIsDestinationSheetOpen(true), 300); + // If the status takes a destination and none applies yet, open the destination sheet. A preset or + // sticky destination the status supports (e.g. the call) is kept and shown instead. + const supportsDestination = getStatusDestinationCapabilities(status.Detail, hasCallContext).supportsDestination; + if (supportsDestination && getEffectiveDestinationType(destinationSelection, status.Detail, hasCallContext) === 'none') { + if (destinationSheetTimerRef.current) clearTimeout(destinationSheetTimerRef.current); + destinationSheetTimerRef.current = setTimeout(() => { + destinationSheetTimerRef.current = null; + setIsDestinationSheetOpen(true); + }, 300); } }; // Handle destination selection const handleDestinationSelect = (type: 'none' | 'call' | 'station' | 'poi', item?: CallResultData | GroupResultData | PoiResultData) => { if (type === 'none') { + // Clears the call, station and POI selections too setStatusDestinationType('none'); - setStatusSelectedCall(null); - setStatusSelectedStation(null); - setStatusSelectedPoi(null); } else if (type === 'call' && item) { setStatusSelectedCall(item as CallResultData); } else if (type === 'station' && item) { @@ -424,7 +474,7 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr setIsDestinationSheetOpen(true)}> - + {t('dispatch.unit_actions_panel.destination')} @@ -506,7 +556,7 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr {t('dispatch.unit_actions_panel.destination')} {/* No Destination Option */} - handleDestinationSelect('none')} label={t('dispatch.unit_actions_panel.no_destination')} /> + handleDestinationSelect('none')} label={t('dispatch.unit_actions_panel.no_destination')} /> {/* Tabs for Calls and Stations */} {destinationConfig.showCalls || destinationConfig.showStations || destinationConfig.showPois ? ( @@ -546,7 +596,7 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr key={call.CallId} type="call" item={call} - isSelected={statusDestinationType === 'call' && statusSelectedCall?.CallId === call.CallId} + isSelected={effectiveDestinationType === 'call' && statusSelectedCall?.CallId === call.CallId} onSelect={() => handleDestinationSelect('call', call)} /> )) @@ -565,7 +615,7 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr key={station.GroupId} type="station" item={station} - isSelected={statusDestinationType === 'station' && statusSelectedStation?.GroupId === station.GroupId} + isSelected={effectiveDestinationType === 'station' && statusSelectedStation?.GroupId === station.GroupId} onSelect={() => handleDestinationSelect('station', station)} /> )) @@ -583,7 +633,7 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr key={poi.PoiId} type="poi" item={poi} - isSelected={statusDestinationType === 'poi' && statusSelectedPoi?.PoiId === poi.PoiId} + isSelected={effectiveDestinationType === 'poi' && statusSelectedPoi?.PoiId === poi.PoiId} onSelect={() => handleDestinationSelect('poi', poi)} /> )) diff --git a/src/components/maps/__tests__/pin-actions.test.tsx b/src/components/maps/__tests__/pin-actions.test.tsx index af932f22..921f7df7 100644 --- a/src/components/maps/__tests__/pin-actions.test.tsx +++ b/src/components/maps/__tests__/pin-actions.test.tsx @@ -144,8 +144,9 @@ jest.mock('@/stores/toast/store', () => ({ }), })); +// Pin ids and types as the Core map API sends them (calls are Type 0 with a `c` prefix). const mockCallPin = { - Id: '123', + Id: 'c123', Title: 'Medical Emergency', Latitude: 40.7128, Longitude: -74.0060, @@ -165,12 +166,12 @@ const mockCallPin = { }; const mockUnitPin = { - Id: '456', + Id: 'u456', Title: 'Engine 1', Latitude: 40.7580, Longitude: -73.9855, ImagePath: 'engine_available', - Type: 2, + Type: 1, InfoWindowContent: 'Engine 1 available', Color: '#00ff00', zIndex: 1, @@ -382,6 +383,29 @@ describe('Pin Actions Integration Tests', () => { expect(mockOnClose).toHaveBeenCalledTimes(1); }); + it('should navigate with a legacy unprefixed call pin id unchanged', () => { + render(); + + fireEvent.press(screen.getByText('map.view_call_details')); + + expect(mockRouter.push).toHaveBeenCalledWith('/call/123'); + }); + + it('should open POI details with the bare POI id', () => { + render( + + ); + + fireEvent.press(screen.getByText('map.view_poi_details')); + + expect(mockRouter.push).toHaveBeenCalledWith('/poi/9'); + }); + it('should not show call details button for non-call pins', () => { render( void>; + flyTo: jest.Mock; +} +const mockMarkers: MockMarker[] = []; +const mockMaps: MockMap[] = []; + +jest.mock('mapbox-gl', () => { + class Popup { + setHTML = jest.fn(() => this); + } + class Marker { + lngLat: [number, number] = [0, 0]; + popup: Popup | undefined; + setLngLat = jest.fn((lngLat: [number, number]) => { + this.lngLat = lngLat; + return this; + }); + remove = jest.fn(); + constructor() { + mockMarkers.push(this as unknown as MockMarker); + } + setPopup(popup: Popup) { + this.popup = popup; + return this; + } + getPopup() { + return this.popup; + } + addTo() { + return this; + } + } + class MapboxMap { + handlers: Record void> = {}; + flyTo = jest.fn(); + constructor() { + mockMaps.push(this as unknown as MockMap); + } + addControl() {} + on(event: string, handler: () => void) { + this.handlers[event] = handler; + } + once() {} + off() {} + remove() {} + setStyle() {} + getLayer() { + return undefined; + } + getSource() { + return undefined; + } + addSource() {} + addLayer() {} + } + class Control {} + return { __esModule: true, default: { Map: MapboxMap, Marker, Popup, NavigationControl: Control, GeolocateControl: Control, accessToken: '' } }; +}); + +jest.mock('@/stores/signalr/signalr-store', () => { + const { create } = jest.requireActual('zustand'); + return { useSignalRStore: create(() => ({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 })) }; +}); + +jest.mock('@/api/mapping/mapping', () => ({ getMapDataAndMarkers: jest.fn() })); +jest.mock('@/lib/map-markers-web', () => ({ buildMapPinPopupHtml: jest.fn(() => '
'), createMapMarkerElement: jest.fn(() => ({})) })); +jest.mock('@/lib/map-center', () => ({ getDepartmentMapCenter: () => ({ latitude: 39, longitude: -119, zoomLevel: 9 }) })); +jest.mock('@/lib/env', () => ({ Env: { MAPBOX_PUBKEY: 'pk.test' } })); +jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() } })); +jest.mock('@/stores/app/location-store', () => ({ useLocationStore: { getState: () => ({ latitude: null, longitude: null }) } })); +jest.mock('nativewind', () => ({ useColorScheme: () => ({ colorScheme: 'light' }) })); + +const mockGetMapDataAndMarkers = getMapDataAndMarkers as jest.MockedFunction; + +const makePin = (id: string, type: number, latitude: number, longitude: number) => ({ Id: id, Type: type, Latitude: latitude, Longitude: longitude, Title: id }) as MapMakerInfoData; + +const mapResponse = (pins: MapMakerInfoData[]) => ({ Data: { MapMakerInfos: pins, CenterLat: '39.5', CenterLon: '-119.8', ZoomLevel: '11', PoiLayers: [] } }) as any; + +const REST_PINS = [makePin('c1', 0, 39.1, -119.1), makePin('u12', 1, 39.2, -119.2), makePin('pABC', 3, 39.3, -119.3)]; + +describe('UnifiedMapView (web) realtime locations', () => { + const originalDocument = (global as any).document; + + beforeEach(() => { + jest.useFakeTimers(); + mockMarkers.length = 0; + mockMaps.length = 0; + mockGetMapDataAndMarkers.mockReset(); + mockGetMapDataAndMarkers.mockResolvedValue(mapResponse(REST_PINS)); + useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 }); + // Only the stylesheet injection touches document; report it as already present. + (global as any).document = { getElementById: () => ({}) }; + }); + + afterEach(() => { + (global as any).document = originalDocument; + jest.useRealTimers(); + }); + + const renderLoadedMap = async () => { + const view = render(, { createNodeMock: () => ({}) }); + act(() => mockMaps[0].handlers.load()); + await waitFor(() => expect(mockMarkers).toHaveLength(REST_PINS.length)); + return view; + }; + + const markerAt = (latitude: number, longitude: number) => mockMarkers.find((marker) => marker.lngLat[0] === longitude && marker.lngLat[1] === latitude); + + it('moves the pushed pin in place without rebuilding markers or moving the camera', async () => { + await renderLoadedMap(); + const unitMarker = markerAt(39.2, -119.2)!; + expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1); + + act(() => { + useSignalRStore.setState({ liveLocations: { u12: { pinId: 'u12', latitude: 40, longitude: -120, timestamp: null, receivedAt: Date.now() } } }); + }); + + expect(unitMarker.setLngLat).toHaveBeenLastCalledWith([-120, 40]); + expect(mockMarkers).toHaveLength(REST_PINS.length); + mockMarkers.forEach((marker) => expect(marker.remove).not.toHaveBeenCalled()); + expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1); + }); + + it('refetches in the background for a pin it does not have, without moving the camera', async () => { + await renderLoadedMap(); + mockGetMapDataAndMarkers.mockResolvedValue(mapResponse([...REST_PINS, makePin('u99', 1, 38, -118)])); + + act(() => { + useSignalRStore.setState({ liveLocations: { u99: { pinId: 'u99', latitude: 38.5, longitude: -118.5, timestamp: null, receivedAt: Date.now() } } }); + }); + expect(mockMarkers).toHaveLength(REST_PINS.length); + + await act(async () => { + jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS); + }); + + await waitFor(() => expect(mockMarkers).toHaveLength(REST_PINS.length + 1)); + expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(2); + // The push arrived before the refetch started, so the fresh REST position stands. + expect(markerAt(38, -118)).toBeDefined(); + expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/maps/pin-detail-modal.tsx b/src/components/maps/pin-detail-modal.tsx index fbf0b2ab..0ad11e60 100644 --- a/src/components/maps/pin-detail-modal.tsx +++ b/src/components/maps/pin-detail-modal.tsx @@ -15,6 +15,7 @@ import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { isCallMarker, isPoiMarker } from '@/lib/destination-helpers'; import { getMapPinSummary, hasValidMapCoordinates } from '@/lib/map-markers'; +import { getPinEntityId } from '@/lib/map-pin-ids'; import { openMapsWithDirections } from '@/lib/navigation'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { useLocationStore } from '@/stores/app/location-store'; @@ -67,15 +68,19 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC }; const handleViewCallDetails = () => { - if (isCallPin && pin.Id) { - router.push(`/call/${pin.Id}` as Href); + const callId = getPinEntityId(pin); + + if (isCallPin && callId) { + router.push(`/call/${callId}` as Href); onClose(); } }; const handleViewPoiDetails = () => { - if (isPoiPin && pin.Id) { - router.push(`/poi/${pin.Id}` as Href); + const poiId = getPinEntityId(pin); + + if (isPoiPin && poiId) { + router.push(`/poi/${poiId}` as Href); onClose(); } }; diff --git a/src/components/maps/unified-map-view.tsx b/src/components/maps/unified-map-view.tsx index b1f37c07..cd2fb6dd 100644 --- a/src/components/maps/unified-map-view.tsx +++ b/src/components/maps/unified-map-view.tsx @@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { StyleSheet, View } from 'react-native'; import { getMapDataAndMarkers } from '@/api/mapping/mapping'; +import { useMapLiveLocations } from '@/hooks/use-map-live-locations'; import { logger } from '@/lib/logging'; import { getDepartmentMapCenter } from '@/lib/map-center'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; @@ -66,6 +67,12 @@ const UnifiedMapViewComponent: React.FC = ({ // Use external pins if provided, otherwise use internal pins const mapPins = externalPins ?? internalPins; + // Realtime unit/personnel positions from the geolocation hub move the pins this view fetched itself; a parent + // that passes `pins` owns them. Refetch requests reuse refreshPins below. + const requestPinsRefreshRef = useRef<(() => void) | null>(null); + const requestPinsRefresh = useCallback(() => requestPinsRefreshRef.current?.(), []); + const { applyToFetchedPins } = useMapLiveLocations({ pins: internalPins, setPins: setInternalPins, requestRefresh: requestPinsRefresh, enabled: autoFetchPins && externalPins === undefined }); + // Get map style based on current theme const getMapStyle = useCallback(() => { return colorScheme === 'dark' ? Mapbox.StyleURL.Dark : Mapbox.StyleURL.Street; @@ -101,11 +108,12 @@ const UnifiedMapViewComponent: React.FC = ({ const fetchMapDataAndMarkers = async () => { try { + const fetchStartedAt = Date.now(); const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal); if (mapDataAndMarkers?.Data) { const markers = mapDataAndMarkers.Data.MapMakerInfos; - setInternalPins(markers); + setInternalPins(applyToFetchedPins(markers, fetchStartedAt)); // Center map on the data center if provided if (mapDataAndMarkers.Data.CenterLat && mapDataAndMarkers.Data.CenterLon && cameraRef.current) { @@ -150,7 +158,45 @@ const UnifiedMapViewComponent: React.FC = ({ return () => { abortController.abort(); }; - }, [autoFetchPins]); + }, [autoFetchPins, applyToFetchedPins]); + + // Background refetch of the pins (update-hub changes, realtime location requests). Never moves the camera. + const refreshPins = useCallback(async () => { + // Abort any in-flight refresh + if (signalRAbortController.current) { + signalRAbortController.current.abort(); + } + const controller = new AbortController(); + signalRAbortController.current = controller; + + try { + logger.debug({ + message: 'Refreshing map pins in the background', + }); + + const fetchStartedAt = Date.now(); + const mapDataAndMarkers = await getMapDataAndMarkers(controller.signal); + if (!controller.signal.aborted && mapDataAndMarkers?.Data) { + setInternalPins(applyToFetchedPins(mapDataAndMarkers.Data.MapMakerInfos, fetchStartedAt)); + } + } catch (error) { + if (error instanceof Error && (error.name === 'AbortError' || error.message === 'canceled')) { + return; + } + logger.error({ + message: 'Failed to refresh map pins', + context: { error }, + }); + } + }, [applyToFetchedPins]); + + useEffect(() => { + requestPinsRefreshRef.current = autoFetchPins + ? () => { + void refreshPins(); + } + : null; + }, [autoFetchPins, refreshPins]); // Refresh pins when SignalR updates come in (only when autoFetchPins is enabled) useEffect(() => { @@ -162,33 +208,8 @@ const UnifiedMapViewComponent: React.FC = ({ } // Debounce to prevent rapid consecutive API calls from multiple SignalR events - signalRDebounceTimer.current = setTimeout(async () => { - // Abort any in-flight SignalR-triggered fetch - if (signalRAbortController.current) { - signalRAbortController.current.abort(); - } - const controller = new AbortController(); - signalRAbortController.current = controller; - - try { - logger.debug({ - message: 'Refreshing map pins from SignalR update', - context: { timestamp: lastUpdateTimestamp }, - }); - - const mapDataAndMarkers = await getMapDataAndMarkers(controller.signal); - if (!controller.signal.aborted && mapDataAndMarkers?.Data) { - setInternalPins(mapDataAndMarkers.Data.MapMakerInfos); - } - } catch (error) { - if (error instanceof Error && (error.name === 'AbortError' || error.message === 'canceled')) { - return; - } - logger.error({ - message: 'Failed to refresh map pins from SignalR update', - context: { error }, - }); - } + signalRDebounceTimer.current = setTimeout(() => { + void refreshPins(); }, 1500); return () => { @@ -199,7 +220,14 @@ const UnifiedMapViewComponent: React.FC = ({ signalRAbortController.current.abort(); } }; - }, [autoFetchPins, lastUpdateTimestamp]); + }, [autoFetchPins, lastUpdateTimestamp, refreshPins]); + + // Abort a background refresh still in flight when the view unmounts. + useEffect(() => { + return () => { + signalRAbortController.current?.abort(); + }; + }, []); // Helper function to get layer style based on type const getLayerStyle = (layer: GetMapLayersData): FillLayerStyle | LineLayerStyle | CircleLayerStyle => { diff --git a/src/components/maps/unified-map-view.web.tsx b/src/components/maps/unified-map-view.web.tsx index 53de5479..5ef0bdcb 100644 --- a/src/components/maps/unified-map-view.web.tsx +++ b/src/components/maps/unified-map-view.web.tsx @@ -5,11 +5,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { StyleSheet, View } from 'react-native'; import { getMapDataAndMarkers } from '@/api/mapping/mapping'; +import { useMapLiveLocations } from '@/hooks/use-map-live-locations'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; import { getDepartmentMapCenter } from '@/lib/map-center'; -import { getMapPinSummary, hasValidMapCoordinates } from '@/lib/map-markers'; -import { createMapMarkerElement } from '@/lib/map-markers-web'; +import { hasValidMapCoordinates } from '@/lib/map-markers'; +import { buildMapPinPopupHtml, createMapMarkerElement } from '@/lib/map-markers-web'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { type GetMapLayersData } from '@/models/v4/mapping/getMapLayersResultData'; import { useLocationStore } from '@/stores/app/location-store'; @@ -47,6 +48,20 @@ interface UnifiedMapViewProps { testID?: string; } +// Helper function to calculate center from markers +const calculateCenterFromMarkers = (markers: MapMakerInfoData[]): { lat: number; lon: number } | null => { + const validMarkers = markers.filter((m) => m.Latitude && m.Longitude); + if (validMarkers.length === 0) return null; + + const sumLat = validMarkers.reduce((sum, m) => sum + m.Latitude, 0); + const sumLon = validMarkers.reduce((sum, m) => sum + m.Longitude, 0); + + return { + lat: sumLat / validMarkers.length, + lon: sumLon / validMarkers.length, + }; +}; + /** * Unified Map View component for Web using mapbox-gl-js. * Supports pins, layers, and user location. @@ -66,7 +81,7 @@ const UnifiedMapViewComponent: React.FC = ({ const mapContainer = useRef(null); const map = useRef(null); const markersRef = useRef>(new Map()); - const markerMetaRef = useRef>(new Map()); + const markerMetaRef = useRef>(new Map()); const onPinPressRef = useRef(onPinPress); const layerIdsRef = useRef([]); const sourceIdsRef = useRef([]); @@ -77,6 +92,12 @@ const UnifiedMapViewComponent: React.FC = ({ // Use external pins if provided, otherwise use internal pins const mapPins = externalPins ?? internalPins; + // Realtime unit/personnel positions from the geolocation hub move the pins this view fetched itself; a parent + // that passes `pins` owns them. Refetch requests reuse fetchPins below. + const requestPinsRefreshRef = useRef<(() => void) | null>(null); + const requestPinsRefresh = useCallback(() => requestPinsRefreshRef.current?.(), []); + const { applyToFetchedPins } = useMapLiveLocations({ pins: internalPins, setPins: setInternalPins, requestRefresh: requestPinsRefresh, enabled: autoFetchPins && externalPins === undefined }); + // Get map style based on current theme const getMapStyle = useCallback(() => { return colorScheme === 'dark' ? 'mapbox://styles/mapbox/dark-v11' : 'mapbox://styles/mapbox/streets-v12'; @@ -152,33 +173,29 @@ const UnifiedMapViewComponent: React.FC = ({ } }, [colorScheme, getMapStyle, isMapReady]); - // Helper function to calculate center from markers - const calculateCenterFromMarkers = (markers: MapMakerInfoData[]): { lat: number; lon: number } | null => { - const validMarkers = markers.filter((m) => m.Latitude && m.Longitude); - if (validMarkers.length === 0) return null; - - const sumLat = validMarkers.reduce((sum, m) => sum + m.Latitude, 0); - const sumLon = validMarkers.reduce((sum, m) => sum + m.Longitude, 0); - - return { - lat: sumLat / validMarkers.length, - lon: sumLon / validMarkers.length, - }; - }; + // Fetches the pins this view shows when auto-fetching: once on mount, and again in the background when the + // realtime location feed asks for it. Only the first successful load may move the camera. + const fetchAbortRef = useRef(null); + const hasCenteredOnDataRef = useRef(false); + const fetchPins = useCallback(async () => { + fetchAbortRef.current?.abort(); + const abortController = new AbortController(); + fetchAbortRef.current = abortController; - // Auto-fetch pins if enabled - useEffect(() => { - if (!autoFetchPins) return; + try { + const fetchStartedAt = Date.now(); + const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal); - const abortController = new AbortController(); + if (abortController.signal.aborted) { + return; + } - const fetchMapDataAndMarkers = async () => { - try { - const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal); + if (mapDataAndMarkers?.Data) { + const markers = mapDataAndMarkers.Data.MapMakerInfos; + setInternalPins(applyToFetchedPins(markers, fetchStartedAt)); - if (mapDataAndMarkers?.Data) { - const markers = mapDataAndMarkers.Data.MapMakerInfos; - setInternalPins(markers); + if (!hasCenteredOnDataRef.current) { + hasCenteredOnDataRef.current = true; // Center map on the data center if provided if (mapDataAndMarkers.Data.CenterLat && mapDataAndMarkers.Data.CenterLon && map.current) { @@ -206,24 +223,42 @@ const UnifiedMapViewComponent: React.FC = ({ } } } - } catch (error) { - if (error instanceof Error && (error.name === 'AbortError' || error.message === 'canceled')) { - return; - } + } + } catch (error) { + if (error instanceof Error && (error.name === 'AbortError' || error.message === 'canceled')) { + return; + } - logger.error({ - message: 'Failed to fetch map data', - context: { error }, - }); + logger.error({ + message: 'Failed to fetch map data', + context: { error }, + }); + } finally { + if (fetchAbortRef.current === abortController) { + fetchAbortRef.current = null; } - }; + } + }, [applyToFetchedPins]); - fetchMapDataAndMarkers(); + useEffect(() => { + requestPinsRefreshRef.current = autoFetchPins + ? () => { + void fetchPins(); + } + : null; + }, [autoFetchPins, fetchPins]); + + // Auto-fetch pins if enabled + useEffect(() => { + if (!autoFetchPins) return; + + void fetchPins(); return () => { - abortController.abort(); + fetchAbortRef.current?.abort(); + fetchAbortRef.current = null; }; - }, [autoFetchPins]); + }, [autoFetchPins, fetchPins]); // Keep a ref to the latest onPinPress so marker click handlers always call the // current callback without forcing a marker rebuild when its identity changes. @@ -238,24 +273,20 @@ const UnifiedMapViewComponent: React.FC = ({ const theme = colorScheme === 'dark' ? 'dark' : 'light'; const seenPinIds = new Set(); - const buildPopupHtml = (pin: MapMakerInfoData) => - `
-

${pin.Title}

- ${getMapPinSummary(pin) ? `

${getMapPinSummary(pin)}

` : ''} -

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

-
`; - mapPins.forEach((pin) => { if (!hasValidMapCoordinates(pin)) return; seenPinIds.add(pin.Id); - const signature = `${theme}:${pin.Title}:${pin.Type}:${pin.PoiTypeId}:${pin.LayerId}:${pin.ImagePath}:${pin.PoiImage}:${pin.Marker}:${pin.Color}:${pin.Address}:${pin.Note}:${pin.PoiTypeName}:${pin.InfoWindowContent}`; const existing = markersRef.current.get(pin.Id); + const currentMeta = markerMetaRef.current.get(pin.Id); + // Live location moves replace only the moved pins' objects, so an unchanged pin is skipped outright + // instead of re-deriving its signature on every realtime update. + if (existing && currentMeta && currentMeta.pin === pin && currentMeta.signature.startsWith(`${theme}:`)) return; + + const signature = `${theme}:${pin.Title}:${pin.Type}:${pin.PoiTypeId}:${pin.LayerId}:${pin.ImagePath}:${pin.PoiImage}:${pin.Marker}:${pin.Color}:${pin.Address}:${pin.Note}:${pin.PoiTypeName}:${pin.InfoWindowContent}`; if (existing) { - const meta = markerMetaRef.current.get(pin.Id); + const meta = currentMeta; const moved = !meta || meta.latitude !== pin.Latitude || meta.longitude !== pin.Longitude; if (moved) { @@ -264,9 +295,9 @@ const UnifiedMapViewComponent: React.FC = ({ if (meta && meta.signature === signature) { if (moved) { - existing.getPopup()?.setHTML(buildPopupHtml(pin)); - markerMetaRef.current.set(pin.Id, { signature, latitude: pin.Latitude, longitude: pin.Longitude }); + existing.getPopup()?.setHTML(buildMapPinPopupHtml(pin)); } + markerMetaRef.current.set(pin.Id, { signature, latitude: pin.Latitude, longitude: pin.Longitude, pin }); return; } @@ -274,18 +305,20 @@ const UnifiedMapViewComponent: React.FC = ({ markersRef.current.delete(pin.Id); } - // Create custom marker element using shared utility + // Create custom marker element using shared utility. The click handler looks the pin up by id so a + // marker that has since moved reports its current data, not the pin it was created from. + const pinId = pin.Id; const el = createMapMarkerElement(pin, theme, () => { - onPinPressRef.current?.(pin); + onPinPressRef.current?.(markerMetaRef.current.get(pinId)?.pin ?? pin); }); // Create popup - const popup = new mapboxgl.Popup({ offset: 25 }).setHTML(buildPopupHtml(pin)); + const popup = new mapboxgl.Popup({ offset: 25 }).setHTML(buildMapPinPopupHtml(pin)); const marker = new mapboxgl.Marker({ element: el }).setLngLat([pin.Longitude, pin.Latitude]).setPopup(popup).addTo(map.current!); markersRef.current.set(pin.Id, marker); - markerMetaRef.current.set(pin.Id, { signature, latitude: pin.Latitude, longitude: pin.Longitude }); + markerMetaRef.current.set(pin.Id, { signature, latitude: pin.Latitude, longitude: pin.Longitude, pin }); }); // Remove stale markers diff --git a/src/components/notifications/NotificationDetail.tsx b/src/components/notifications/NotificationDetail.tsx index c3cea735..18dd3feb 100644 --- a/src/components/notifications/NotificationDetail.tsx +++ b/src/components/notifications/NotificationDetail.tsx @@ -1,9 +1,11 @@ import { useNotifications } from '@novu/react-native'; import { useColorScheme } from 'nativewind'; import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { Animated, Dimensions, Platform, Pressable, SafeAreaView, StatusBar, type StyleProp, StyleSheet, Text, View, type ViewStyle } from 'react-native'; import { ArrowLeft, Calendar, ExternalLink, Trash2 } from '@/components/ui/lucide-icons'; +import { hasReferenceRoute } from '@/lib/notifications/inbox-reference'; // Define the interface directly in this file interface NotificationPayload { @@ -30,6 +32,7 @@ const SIDEBAR_WIDTH = Math.min(width * 0.85, 400); const STATUS_BAR_HEIGHT = Platform.OS === 'ios' ? 44 : StatusBar.currentHeight || 0; export const NotificationDetail = ({ notification, onClose, onDelete, onNavigateToReference }: NotificationDetailProps) => { + const { t } = useTranslation(); const styles = useStyles(); const { refetch } = useNotifications(); const slideAnim = React.useRef(new Animated.Value(SIDEBAR_WIDTH)).current; @@ -111,7 +114,7 @@ export const NotificationDetail = ({ notification, onClose, onDelete, onNavigate - Notification + {t('notifications.detail_title')} @@ -140,7 +143,7 @@ export const NotificationDetail = ({ notification, onClose, onDelete, onNavigate {notification.metadata && Object.keys(notification.metadata).length > 0 ? ( - Additional Information + {t('notifications.additional_info')} {Object.entries(notification.metadata).map(([key, value]) => ( {formatKey(key)}: @@ -150,10 +153,12 @@ export const NotificationDetail = ({ notification, onClose, onDelete, onNavigate ) : null} - {notification.referenceType && notification.referenceId ? ( - + {hasReferenceRoute(notification.referenceType, notification.referenceId) ? ( + - View {notification.referenceType} + + {notification.referenceType === 'call' ? t('notifications.view_call') : notification.referenceType === 'chat' ? t('notifications.view_chat') : t('notifications.open_reference')} + ) : null} diff --git a/src/components/notifications/NotificationInbox.tsx b/src/components/notifications/NotificationInbox.tsx index e0e4cd87..d08d8ffc 100644 --- a/src/components/notifications/NotificationInbox.tsx +++ b/src/components/notifications/NotificationInbox.tsx @@ -1,4 +1,5 @@ import { useNotifications } from '@novu/react-native'; +import { router } from 'expo-router'; import { useColorScheme } from 'nativewind'; import React, { useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Animated, Dimensions, Platform, Pressable, RefreshControl, SafeAreaView, StatusBar, StyleSheet, View } from 'react-native'; @@ -10,6 +11,8 @@ import { FlatList } from '@/components/ui/flat-list'; import { CheckCircle, ChevronRight, Circle, ExternalLink, MoreVertical, Trash2, X } from '@/components/ui/lucide-icons'; import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/ui/modal'; import { Text } from '@/components/ui/text'; +import { useAuthStore } from '@/lib/auth'; +import { hasReferenceRoute, referenceFromEventCode, referenceHref } from '@/lib/notifications/inbox-reference'; import { useCoreStore } from '@/stores/app/core-store'; import { useToastStore } from '@/stores/toast/store'; import { type NotificationPayload } from '@/types/notification'; @@ -24,9 +27,42 @@ interface NotificationInboxProps { onClose: () => void; } +/** The notification item shape returned by Novu's useNotifications hook. */ +type NovuNotification = NonNullable['notifications']>[number]; + +const REFERENCE_TYPES = ['call', 'message', 'status', 'note', 'chat', 'other'] as const; + +const asString = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined); + +const asReferenceType = (value: unknown): NotificationPayload['referenceType'] => REFERENCE_TYPES.find((candidate) => candidate === value); + +/** + * Maps a Novu inbox item (@novu/js v3: subject, isRead, data). The v2 names this inbox used to read + * (title, read, payload) are undefined or, for `read`, a method, so every row looked read, had no title + * and never carried a reference. The Novu bridge puts the push event code in `data`, from which the call + * or chat the notification is about is derived; the code itself is routing, not "Additional Information". + */ +export const mapNovuNotification = (item: NovuNotification): NotificationPayload => { + const data = item.data; + const reference = referenceFromEventCode(data?.eventCode); + + return { + id: item.id, + title: item.subject, + body: item.body, + createdAt: item.createdAt, + read: item.isRead, + type: asString(data?.type), + referenceId: reference?.referenceId ?? asString(data?.referenceId), + referenceType: reference?.referenceType ?? asReferenceType(data?.referenceType), + metadata: data ? Object.fromEntries(Object.entries(data).filter(([key]) => key !== 'eventCode')) : undefined, + }; +}; + export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) => { const styles = useStyles(); - const activeUnitId = useCoreStore((state) => state.activeUnitId); + // The inbox is the dispatcher's own ({code}_User_{id}, the NovuProvider subscriber), not a unit's. + const userId = useAuthStore((state) => state.userId); const config = useCoreStore((state: any) => state.config); const { notifications, isLoading, fetchMore, hasMore, refetch } = useNotifications(); const showToast = useToastStore((state) => state.showToast); @@ -155,23 +191,17 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = ); const handleNavigateToReference = (referenceType: string, referenceId: string) => { - // TODO: Implement navigation based on reference type - console.log('Navigate to:', referenceType, referenceId); + const href = referenceHref(referenceType, referenceId); + // Nothing to open: stay in the inbox rather than closing onto the screen the dispatcher was already on. + if (!href) return; + setSelectedNotification(null); onClose(); + router.push(href); }; - const renderItem = ({ item }: { item: any }) => { - const notification: NotificationPayload = { - id: item.id, - title: item.title, - body: item.body, - createdAt: item.createdAt, - read: item.read, - type: item.type, - referenceId: item.payload?.referenceId, - referenceType: item.payload?.referenceType, - metadata: item.payload?.metadata, - }; + const renderItem = ({ item }: { item: NovuNotification }) => { + const notification = mapNovuNotification(item); + const unread = !notification.read; const isSelected = selectedNotificationIds.has(notification.id); @@ -184,9 +214,9 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = toggleNotificationSelection(notification.id); } }} - style={[styles.notificationItem, !item.read ? styles.unreadNotificationItem : {}, isSelected ? styles.selectedNotificationItem : {}]} + style={[styles.notificationItem, unread ? styles.unreadNotificationItem : {}, isSelected ? styles.selectedNotificationItem : {}]} > - {!item.read ? : null} + {unread ? : null} {isSelectionMode ? ( @@ -195,16 +225,16 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = ) : null} - {notification.body} + {notification.body} {new Date(notification.createdAt).toLocaleDateString()} {new Date(notification.createdAt).toLocaleTimeString()} {!isSelectionMode ? ( - notification.referenceType && notification.referenceId ? ( + hasReferenceRoute(notification.referenceType, notification.referenceId) ? ( - @@ -238,7 +268,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = } // Additional safety check to prevent rendering overlay without proper config - if (!activeUnitId || !config || !config.NovuApplicationId || !config.NovuBackendApiUrl || !config.NovuSocketUrl) { + if (!userId || !config || !config.NovuApplicationId || !config.NovuBackendApiUrl || !config.NovuSocketUrl) { return null; } @@ -293,7 +323,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = - ) : !activeUnitId || !config ? ( + ) : !userId || !config ? ( Unable to load notifications diff --git a/src/components/notifications/__tests__/notification-references.test.tsx b/src/components/notifications/__tests__/notification-references.test.tsx new file mode 100644 index 00000000..4f7b7d72 --- /dev/null +++ b/src/components/notifications/__tests__/notification-references.test.tsx @@ -0,0 +1,122 @@ +/** + * The real inbox and detail with their data sources mocked (the sibling NotificationInbox.test mocks + * the inbox itself). Covers the Novu v2 -> v3 field fix (the inbox read `title`/`read`/`payload`, so rows + * had no title, all looked read and never carried a reference), the inbox opening for a dispatcher who + * has no active unit, and call/chat links derived from the push event code the Novu bridge puts in `data`. + */ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { router } from 'expo-router'; +import React from 'react'; + +jest.mock('expo-router', () => ({ router: { push: jest.fn() } })); +jest.mock('@novu/react-native', () => ({ useNotifications: jest.fn() })); +jest.mock('@/stores/app/core-store', () => ({ useCoreStore: jest.fn() })); +jest.mock('@/stores/toast/store', () => ({ useToastStore: jest.fn() })); +jest.mock('@/lib/auth', () => ({ useAuthStore: jest.fn() })); +jest.mock('@/api/novu/inbox', () => ({ deleteMessage: jest.fn().mockResolvedValue(undefined) })); +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => ({ 'notifications.view_call': 'View Call', 'notifications.view_chat': 'View Chat', 'notifications.open_reference': 'Open', 'notifications.detail_title': 'Notification' })[key] ?? key, + }), +})); + +import { useNotifications } from '@novu/react-native'; + +import { useAuthStore } from '@/lib/auth'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useToastStore } from '@/stores/toast/store'; + +import { NotificationDetail } from '../NotificationDetail'; +import { mapNovuNotification, NotificationInbox } from '../NotificationInbox'; + +type NovuItem = Parameters[0]; +// `read` is a method on a real @novu/js v3 Notification; the old mapping read it as the flag. +const item = (data: Record | undefined, id = 'n-1', isRead = false) => + ({ id, subject: 'Structure Fire', body: 'Engine 6 respond', createdAt: '2026-09-22T10:00:00Z', isRead, read: () => undefined, data }) as unknown as NovuItem; + +const withItems = (items: NovuItem[]) => (useNotifications as jest.Mock).mockReturnValue({ notifications: items, isLoading: false, fetchMore: jest.fn(), hasMore: false, refetch: jest.fn() }); + +beforeEach(() => { + jest.clearAllMocks(); + // A dispatcher: signed in, no active unit. + (useCoreStore as unknown as jest.Mock).mockImplementation((selector: (state: unknown) => unknown) => + selector({ activeUnitId: null, config: { NovuApplicationId: 'app', NovuBackendApiUrl: 'api', NovuSocketUrl: 'socket' } }) + ); + (useAuthStore as unknown as jest.Mock).mockImplementation((selector: (state: unknown) => unknown) => selector({ userId: 'dispatcher-1' })); + (useToastStore as unknown as jest.Mock).mockImplementation((selector: (state: unknown) => unknown) => selector({ showToast: jest.fn() })); +}); + +describe('mapNovuNotification', () => { + it('reads the v3 fields and derives call and chat links from the event code', () => { + expect(mapNovuNotification(item({ eventCode: 'C1234' }))).toEqual(expect.objectContaining({ title: 'Structure Fire', read: false, referenceType: 'call', referenceId: '1234', metadata: {} })); + expect(mapNovuNotification(item({ eventCode: 'g:7f1c' }, 'n-2', true))).toEqual(expect.objectContaining({ read: true, referenceType: 'chat', referenceId: '7f1c' })); + // Work orders open only in the Responder app. + expect(mapNovuNotification(item({ eventCode: 'NWO:0b7c3e52-2f4a-4d0e-9a57-1f7a0c9d6e11' })).referenceType).toBeUndefined(); + }); +}); + +describe('NotificationInbox', () => { + it('opens for a signed-in dispatcher without an active unit', () => { + withItems([item({ eventCode: '' }, 'plain-1')]); + + render(); + + expect(screen.getByText('Engine 6 respond')).toBeTruthy(); + expect(screen.queryByTestId('notification-reference-plain-1')).toBeNull(); + }); + + it('opens the dispatched call and closes the inbox', () => { + withItems([item({ eventCode: 'C1234' }, 'call-1')]); + const onClose = jest.fn(); + + render(); + fireEvent.press(screen.getByTestId('notification-reference-call-1')); + + expect(router.push).toHaveBeenCalledWith({ pathname: '/call/[id]', params: { id: '1234' } }); + expect(onClose).toHaveBeenCalled(); + }); + + it('offers no link for a reference type this app cannot open', () => { + withItems([item({ referenceType: 'note', referenceId: '55' }, 'note-1')]); + + render(); + + expect(screen.getByText('Engine 6 respond')).toBeTruthy(); + expect(screen.queryByTestId('notification-reference-note-1')).toBeNull(); + }); + + it('opens the chat conversation', () => { + withItems([item({ eventCode: 't:9a2b' }, 'chat-1')]); + + render(); + fireEvent.press(screen.getByTestId('notification-reference-chat-1')); + + expect(router.push).toHaveBeenCalledWith({ pathname: '/chat/[channelId]', params: { channelId: '9a2b' } }); + }); +}); + +describe('NotificationDetail reference button', () => { + const base = { id: 'n-1', title: 'Structure Fire', body: 'Engine 6 respond', createdAt: '2026-09-22T10:00:00Z', read: true }; + + it.each([ + ['call', 'View Call'], + ['chat', 'View Chat'], + ])('labels a %s reference as %s', (referenceType, label) => { + withItems([]); + const onNavigateToReference = jest.fn(); + render(); + + expect(screen.getByText(label)).toBeTruthy(); + expect(screen.getByText('Notification')).toBeTruthy(); + fireEvent.press(screen.getByTestId('notification-detail-reference')); + expect(onNavigateToReference).toHaveBeenCalledWith(referenceType, 'ref-1'); + }); + + it('offers no link for a reference this app has no screen for', () => { + withItems([]); + render(); + + expect(screen.getByText('Notification')).toBeTruthy(); + expect(screen.queryByTestId('notification-detail-reference')).toBeNull(); + }); +}); diff --git a/src/components/operations/expenses-panel.tsx b/src/components/operations/expenses-panel.tsx new file mode 100644 index 00000000..36b75fdc --- /dev/null +++ b/src/components/operations/expenses-panel.tsx @@ -0,0 +1,154 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert } from 'react-native'; + +import { OptionSelect } from '@/components/operations/option-select'; +import { Button, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField } from '@/components/ui/input'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { type CapturedPhoto, capturePhoto, discardPhoto, PhotoPermissionError } from '@/lib/media/photo'; +import { dateOf } from '@/lib/operations/time'; +import type { Expense, ExpenseInput } from '@/models/v4/operations'; +import { ExpenseType } from '@/models/v4/operations'; + +interface ExpensesPanelProps { + dateKey: string; + currency?: string | null; + expenses: Expense[]; + /** The open report the expense belongs to, when the person may act on it. */ + reportId: string | null; + userId: string | null; + canManage: boolean; + canAdd: boolean; + busy: boolean; + onAdd: (input: Omit) => Promise; + onRemove: (id: string) => Promise; +} + +const amountOf = (text: string) => { + const value = Number(text.replace(',', '.')); + return Number.isFinite(value) && value >= 0 ? Math.round(value * 100) / 100 : null; +}; + +// Field expenses against the deployment (fuel, meals, lodging, restock) with a receipt photo. The receipt is +// re-encoded (no GPS or device metadata) and filed by the server as a Receipt attachment; a member removes +// only the expenses they added. +export const ExpensesPanel = ({ dateKey, currency, expenses, reportId, userId, canManage, canAdd, busy, onAdd, onRemove }: ExpensesPanelProps) => { + const { t } = useTranslation(); + const [type, setType] = useState(String(ExpenseType.Fuel)); + const [amount, setAmount] = useState(''); + const [description, setDescription] = useState(''); + const [city, setCity] = useState(''); + const [receipt, setReceipt] = useState(null); + const [message, setMessage] = useState(null); + const typeOptions = Object.values(ExpenseType).map((value) => ({ value: String(value), label: t(`operations.expenses.type.${value}`) })); + const day = expenses.filter((expense) => dateOf(expense.ExpenseDate) === dateKey); + const others = expenses.filter((expense) => dateOf(expense.ExpenseDate) !== dateKey); + + const pick = async (source: 'camera' | 'library') => { + setMessage(null); + try { + const photo = await capturePhoto(source, `receipt-${dateKey}.jpg`); + if (photo) { + await discardPhoto(receipt); + setReceipt(photo); + } + } catch (error) { + setMessage(error instanceof PhotoPermissionError ? t('operations.expenses.photoDenied') : t('operations.errors.retry')); + } + }; + + const add = async () => { + const value = amountOf(amount); + if (value == null) { + setMessage(t('operations.expenses.amountInvalid')); + return; + } + const ok = await onAdd({ + ExpenseDate: dateKey, + ExpenseType: Number(type), + Amount: value, + Currency: currency ?? null, + Description: description.trim() || null, + City: city.trim() || null, + TimeReportId: reportId, + ReceiptData: receipt?.base64 ?? null, + ReceiptFileName: receipt?.name ?? null, + ReceiptContentType: receipt?.contentType ?? null, + }); + if (ok) { + await discardPhoto(receipt); + setReceipt(null); + setAmount(''); + setDescription(''); + setCity(''); + setMessage(t('operations.expenses.saved')); + } + }; + + const confirmRemove = (expense: Expense) => + Alert.alert(t('operations.expenses.removeTitle'), t('operations.expenses.removeBody'), [ + { text: t('operations.expenses.cancel'), style: 'cancel' }, + { text: t('operations.expenses.remove'), style: 'destructive', onPress: () => void onRemove(expense.Id) }, + ]); + + const row = (expense: Expense) => ( + + + + {t(`operations.expenses.type.${expense.ExpenseType}`)} · {expense.Amount.toFixed(2)} {expense.Currency ?? ''} + + + {[dateOf(expense.ExpenseDate), expense.Description, expense.City, expense.ReceiptAttachmentId ? t('operations.expenses.hasReceipt') : null].filter(Boolean).join(' · ')} + + + {canManage || (!!userId && expense.AddedByUserId === userId) ? ( + + ) : null} + + ); + + return ( + + {day.length === 0 ? {t('operations.expenses.noneToday')} : day.map(row)} + {canAdd ? ( + + {t('operations.expenses.add')} + + + + + + + + + + + + + + + {receipt ? {t('operations.expenses.receiptAttached')} : null} + + + ) : null} + {message ? {message} : null} + {others.length > 0 ? ( + + {t('operations.expenses.otherDays')} + {others.map(row)} + + ) : null} + + ); +}; diff --git a/src/components/operations/scope-picker.tsx b/src/components/operations/scope-picker.tsx new file mode 100644 index 00000000..7345ef26 --- /dev/null +++ b/src/components/operations/scope-picker.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView } from 'react-native'; + +import { Button, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { dateOf, reportScope, scopeKey, scopeName, sumHours, type TimeScope } from '@/lib/operations/time'; +import type { Deployment, TimeReport } from '@/models/v4/operations'; + +export const useScopeLabel = () => { + const { t } = useTranslation(); + return (deployment: Deployment, scope: TimeScope | null) => { + if (!scope) return ''; + if (scope.kind === 'deployment') return t('operations.scope.deployment'); + if (scope.kind === 'individual' && scope.personnelId === deployment.TimeAccess?.PersonnelId) return t('operations.scope.mine'); + return scope.kind === 'crew' ? t('operations.scope.crew', { name: scopeName(deployment, scope) }) : t('operations.scope.individual', { name: scopeName(deployment, scope) }); + }; +}; + +interface ScopePickerProps { + deployment: Deployment; + scopes: TimeScope[]; + current: TimeScope | null; + onPick: (scope: TimeScope) => void; +} + +// Which report the person is working on: their crew's Crew Time Report, their own time, or — for a manager — +// the deployment-wide DTR. Only the scopes the server's TimeAccess grants are offered. +export const ScopePicker = ({ deployment, scopes, current, onPick }: ScopePickerProps) => { + const label = useScopeLabel(); + if (scopes.length < 2) return null; + return ( + + {scopes.map((scope) => ( + + ))} + + ); +}; + +interface DayReportsProps { + deployment: Deployment; + reports: TimeReport[]; + openId: string | null; + onOpen: (report: TimeReport) => void; +} + +// Every live report on the day (managers and approvers): who it covers, its status and hours, so a supervisor +// can open each crew's report to review and approve it. +export const DayReports = ({ deployment, reports, openId, onOpen }: DayReportsProps) => { + const { t } = useTranslation(); + const label = useScopeLabel(); + if (reports.length === 0) return null; + return ( + + {t('operations.time.dayReports')} + {reports.map((report) => ( + onOpen(report)} testID={`operations-day-report-${report.Id}`} className={`rounded-lg border p-2 ${report.Id === openId ? 'border-primary-500' : 'border-outline-200'}`}> + + + #{report.ReportNumber} · {label(deployment, reportScope(report))} + + + {t(`operations.reportStatus.${report.Status}`)} · {t('operations.time.hours', { hours: sumHours(report.Entries) })} + + + {dateOf(report.ReportDate)} + + ))} + + ); +}; + +interface AwaitingApprovalProps { + deployment: Deployment; + reports: TimeReport[]; + onOpen: (report: TimeReport) => void; +} + +/** Submitted reports on any day, oldest first: the approver's queue for this deployment. */ +export const AwaitingApproval = ({ deployment, reports, onOpen }: AwaitingApprovalProps) => { + const { t } = useTranslation(); + const label = useScopeLabel(); + if (reports.length === 0) return null; + return ( + + {t('operations.time.awaitingApproval', { count: reports.length })} + {reports.map((report) => ( + onOpen(report)} testID={`operations-awaiting-${report.Id}`}> + + {dateOf(report.ReportDate)} · #{report.ReportNumber} · {label(deployment, reportScope(report))} + + + ))} + + ); +}; diff --git a/src/components/operations/time-report-editor.tsx b/src/components/operations/time-report-editor.tsx index 9b25d5b6..bed50fc4 100644 --- a/src/components/operations/time-report-editor.tsx +++ b/src/components/operations/time-report-editor.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { OptionSelect } from '@/components/operations/option-select'; @@ -9,29 +9,41 @@ import { Switch } from '@/components/ui/switch'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import type { EntrySubject } from '@/lib/operations/time'; -import { combineDateTime, entryHours, isClockTime, isReportEditable, newEntry, subjectLabel, sumHours, timeOf } from '@/lib/operations/time'; +import { applyToCrew, combineDateTime, dateOf, endLocalFor, entryHours, hoursBySubject, isClockTime, isReportEditable, newEntry, subjectIdOf, sumHours, timeOf } from '@/lib/operations/time'; import type { TimeEntry, TimeReport, TimeReportIssue } from '@/models/v4/operations'; -import { TimeEntryType } from '@/models/v4/operations'; +import { TimeEntryType, TimeReportScope, TimeReportStatus, TimeSubjectType } from '@/models/v4/operations'; interface TimeReportEditorProps { dateKey: string; report: TimeReport | null; entries: TimeEntry[]; + /** Subjects the person may add rows for in this report's scope. */ subjects: EntrySubject[]; + /** Roster names by subject id, including released members, for stored rows. */ + names: Record; + /** Short name of the crew or person the report covers ("Engine 41", "A. Smith"); empty for a deployment DTR. */ + scopeLabel: string; + isCrew: boolean; + /** Set when this person's or crew's time for the day already sits on another report. */ + coveredBy: TimeReport | null; issues: TimeReportIssue[]; warnings: TimeReportIssue[]; - canEdit: boolean; + canCreate: boolean; + canApprove: boolean; + canWrite: (subjectId: string) => boolean; dirty: boolean; busy: boolean; onStart: () => void; onChange: (entries: TimeEntry[]) => void; onSave: () => void; onSubmit: () => void; + onSign: (crewBossSigned: boolean, customerSignerName?: string | null) => void; + onApprove: () => void; } // Keeps the keystrokes local and only commits a complete HH:mm, so a half-typed time never lands // in the entry as an unparseable value. -const ClockField = ({ value, onCommit, isDisabled, testID }: { value: string; onCommit: (clock: string) => void; isDisabled: boolean; testID: string }) => { +const ClockField = ({ value, onCommit, isDisabled, testID, label }: { value: string; onCommit: (clock: string) => void; isDisabled: boolean; testID: string; label: string }) => { const [text, setText] = useState(value); useEffect(() => { setText(value); @@ -40,6 +52,7 @@ const ClockField = ({ value, onCommit, isDisabled, testID }: { value: string; on { +const typeRank = (entry: TimeEntry) => (entry.SubjectType === TimeSubjectType.Unit ? 0 : entry.SubjectType === TimeSubjectType.Personnel ? 1 : 2); + +// A crew or individual time report for one day (the paper CTR / OF-288 analog). On a crew report the unit's +// row leads; "Apply to crew" copies its shift to every crew member and piece of equipment on the same entry +// type, which is how most crews fill the form, and rows that differ are edited one by one. The crew boss +// signs, the agency signer's name can be recorded, and the report is submitted for approval. +export const TimeReportEditor = ({ + dateKey, + report, + entries, + subjects, + names, + scopeLabel, + isCrew, + coveredBy, + issues, + warnings, + canCreate, + canApprove, + canWrite, + dirty, + busy, + onStart, + onChange, + onSave, + onSubmit, + onSign, + onApprove, +}: TimeReportEditorProps) => { const { t } = useTranslation(); - const editable = canEdit && isReportEditable(report); + const [customerSigner, setCustomerSigner] = useState(''); + const editable = isReportEditable(report); const typeOptions = [ { value: String(TimeEntryType.Deployment), label: t('operations.entryType.0') }, { value: String(TimeEntryType.Standby), label: t('operations.entryType.1') }, { value: String(TimeEntryType.Travel), label: t('operations.entryType.2') }, ]; const subjectOptions = subjects.map((subject) => ({ value: `${subject.type}:${subject.id}`, label: subject.label })); + // Unit first, then crew, then equipment; indexes still point into the saved order. + const order = useMemo(() => entries.map((entry, index) => ({ entry, index })).sort((a, b) => typeRank(a.entry) - typeRank(b.entry) || a.index - b.index), [entries]); + const perSubject = useMemo(() => hoursBySubject(entries), [entries]); const update = (index: number, patch: Partial) => onChange(entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry))); const remove = (index: number) => onChange(entries.filter((_, i) => i !== index).map((entry, i) => ({ ...entry, SortOrder: i }))); const add = () => { @@ -74,101 +119,152 @@ export const TimeReportEditor = ({ dateKey, report, entries, subjects, issues, w const blank = newEntry(subject, dateKey, index); update(index, { SubjectType: blank.SubjectType, DeploymentPersonnelId: blank.DeploymentPersonnelId, DeploymentUnitId: blank.DeploymentUnitId, DeploymentEquipmentId: blank.DeploymentEquipmentId }); }; + const setStart = (index: number, clock: string) => { + const entry = entries[index]; + const startLocal = combineDateTime(dateOf(entry.StartLocal) || dateKey, clock); + update(index, { StartLocal: startLocal, EndLocal: endLocalFor(startLocal, timeOf(entry.EndLocal) || clock) }); + }; const issueFor = (index: number) => issues.filter((issue) => issue.EntryId === String(index) || (!!entries[index]?.Id && issue.EntryId === entries[index]?.Id)); + const issueText = (issue: TimeReportIssue) => t(`operations.issues.${issue.Code}`, { defaultValue: issue.Detail ?? issue.Code }); + const title = !report + ? '' + : report.Scope === TimeReportScope.Crew + ? t('operations.time.crewReport', { number: report.ReportNumber, name: scopeLabel }) + : report.Scope === TimeReportScope.Individual + ? t('operations.time.individualReport', { number: report.ReportNumber, name: scopeLabel }) + : t('operations.time.report', { number: report.ReportNumber }); if (!report) { return ( - {t('operations.time.noReport')} - {canEdit ? ( + {isCrew ? t('operations.time.noCrewReport', { name: scopeLabel }) : t('operations.time.noReport')} + {coveredBy ? {t('operations.time.coveredBy', { number: coveredBy.ReportNumber })} : null} + {canCreate && !coveredBy ? ( ) : null} ); } + const signable = report.CanAct && (report.Status === TimeReportStatus.Draft || report.Status === TimeReportStatus.Submitted); + return ( - {t('operations.time.report', { number: report.ReportNumber })} + {title} {t(`operations.reportStatus.${report.Status}`)} + {!report.CanAct ? {t('operations.time.readOnly')} : null} {entries.length === 0 ? {t('operations.time.noEntries')} : null} - {entries.map((entry, index) => ( - - setSubject(index, value)} - isDisabled={!editable} - testID={`operations-entry-subject-${index}`} - /> - update(index, { EntryType: Number(value) })} - isDisabled={!editable} - testID={`operations-entry-type-${index}`} - /> - - {t('operations.time.from')} - update(index, { StartTime: combineDateTime(dateKey, clock) })} isDisabled={!editable} testID={`operations-entry-start-${index}`} /> - {t('operations.time.to')} - update(index, { EndTime: combineDateTime(dateKey, clock) })} isDisabled={!editable} testID={`operations-entry-end-${index}`} /> - - - {t('operations.time.unpaidBreak')} - - update(index, { UnpaidBreakMinutes: Math.max(0, Number(text) || 0) })} - testID={`operations-entry-break-${index}`} + {order.map(({ entry, index }) => { + const subjectId = subjectIdOf(entry); + const rowEditable = editable && canWrite(subjectId); + const label = names[subjectId] ?? ''; + return ( + + {entry.Id || !rowEditable ? ( + {label || t('operations.time.subject')} + ) : ( + setSubject(index, value)} + testID={`operations-entry-subject-${index}`} + /> + )} + update(index, { EntryType: Number(value) })} + isDisabled={!rowEditable} + testID={`operations-entry-type-${index}`} + /> + + {t('operations.time.from')} + setStart(index, clock)} isDisabled={!rowEditable} testID={`operations-entry-start-${index}`} /> + {t('operations.time.to')} + update(index, { EndLocal: endLocalFor(entry.StartLocal, clock) })} + isDisabled={!rowEditable} + testID={`operations-entry-end-${index}`} /> + + {dateOf(entry.EndLocal) > dateOf(entry.StartLocal) ? {t('operations.time.nextDay')} : null} + + {t('operations.time.unpaidBreak')} + + update(index, { UnpaidBreakMinutes: Math.max(0, Number(text) || 0) })} + testID={`operations-entry-break-${index}`} + /> + + + + {t('operations.time.meals')} + update(index, { AgencySuppliedMeals: value })} isDisabled={!rowEditable} /> + + + {t('operations.time.accommodation')} + update(index, { AgencySuppliedAccommodation: value })} isDisabled={!rowEditable} /> + + + update(index, { Notes: text || null })} testID={`operations-entry-notes-${index}`} /> - - - {t('operations.time.meals')} - update(index, { AgencySuppliedMeals: value })} isDisabled={!editable} /> - - - {t('operations.time.accommodation')} - update(index, { AgencySuppliedAccommodation: value })} isDisabled={!editable} /> - - - update(index, { Notes: text || null })} testID={`operations-entry-notes-${index}`} /> - - - {t('operations.time.hours', { hours: entryHours(entry) })} - {editable ? ( - - ) : null} - - {issueFor(index).map((issue, i) => ( - - {t(`operations.issues.${issue.Code}`, { defaultValue: issue.Detail ?? issue.Code })} - - ))} - - ))} + + {t('operations.time.hours', { hours: entryHours(entry) })} + {rowEditable ? ( + + {isCrew && entries.length > 1 ? ( + + ) : null} + + + ) : null} + + {issueFor(index).map((issue, i) => ( + + {issueText(issue)} + + ))} + + ); + })} {issues .filter((issue) => !issue.EntryId || !entries.some((entry, index) => issue.EntryId === String(index) || issue.EntryId === entry.Id)) .map((issue, i) => ( - {t(`operations.issues.${issue.Code}`, { defaultValue: issue.Detail ?? issue.Code })} + {issueText(issue)} + {issue.SubjectId && names[issue.SubjectId] ? ` — ${names[issue.SubjectId]}` : ''} ))} {warnings.map((warning, i) => ( - {t(`operations.issues.${warning.Code}`, { defaultValue: warning.Detail ?? warning.Code })} + {issueText(warning)} + {warning.SubjectId && names[warning.SubjectId] ? ` — ${names[warning.SubjectId]}` : ''} ))} + {Object.keys(perSubject).length > 1 ? ( + + {Object.entries(perSubject).map(([subjectId, hours]) => ( + + {names[subjectId] ?? subjectId} + {t('operations.time.hours', { hours })} + + ))} + + ) : null} {t('operations.time.total', { hours: sumHours(entries) })} {editable ? ( @@ -183,6 +279,43 @@ export const TimeReportEditor = ({ dateKey, report, entries, subjects, issues, w ) : null} + {signable || report.ContractorSignedOn || report.CustomerSignedOn ? ( + + {t('operations.sign.title')} + + {report.ContractorSignedOn ? t('operations.sign.crewBossSigned', { when: String(report.ContractorSignedOn).slice(0, 16).replace('T', ' ') }) : t('operations.sign.crewBossPending')} + + {report.CustomerSignedOn ? t('operations.sign.customerSigned', { name: report.CustomerSignerName ?? '' }) : t('operations.sign.customerPending')} + {signable ? ( + <> + {!report.ContractorSignedOn ? ( + + ) : null} + + + + + + ) : null} + + ) : null} + {canApprove && report.Status === TimeReportStatus.Submitted ? ( + + ) : null}
); }; diff --git a/src/components/settings/__tests__/login-info-bottom-sheet-simple.test.tsx b/src/components/settings/__tests__/login-info-bottom-sheet-simple.test.tsx index 4035edc8..fe49c999 100644 --- a/src/components/settings/__tests__/login-info-bottom-sheet-simple.test.tsx +++ b/src/components/settings/__tests__/login-info-bottom-sheet-simple.test.tsx @@ -215,7 +215,7 @@ describe('LoginInfoBottomSheet', () => { it('calls onClose when cancel button is pressed', () => { render(); - const cancelButton = screen.getByText('common.cancel').parent; + const cancelButton = screen.getByText('common.cancel').parent!; fireEvent.press(cancelButton); expect(mockOnClose).toHaveBeenCalled(); diff --git a/src/components/settings/__tests__/login-info-bottom-sheet.test.tsx b/src/components/settings/__tests__/login-info-bottom-sheet.test.tsx index 4035edc8..fe49c999 100644 --- a/src/components/settings/__tests__/login-info-bottom-sheet.test.tsx +++ b/src/components/settings/__tests__/login-info-bottom-sheet.test.tsx @@ -215,7 +215,7 @@ describe('LoginInfoBottomSheet', () => { it('calls onClose when cancel button is pressed', () => { render(); - const cancelButton = screen.getByText('common.cancel').parent; + const cancelButton = screen.getByText('common.cancel').parent!; fireEvent.press(cancelButton); expect(mockOnClose).toHaveBeenCalled(); diff --git a/src/components/settings/__tests__/server-url-bottom-sheet-simple.test.tsx b/src/components/settings/__tests__/server-url-bottom-sheet-simple.test.tsx index 514176db..47217758 100644 --- a/src/components/settings/__tests__/server-url-bottom-sheet-simple.test.tsx +++ b/src/components/settings/__tests__/server-url-bottom-sheet-simple.test.tsx @@ -29,6 +29,8 @@ jest.mock('react-hook-form', () => ({ control: {}, handleSubmit: jest.fn(), setValue: jest.fn(), + setError: jest.fn(), + clearErrors: jest.fn(), formState: { errors: {} }, }), Controller: ({ render }: any) => render({ field: { onChange: jest.fn(), value: '' } }), @@ -42,7 +44,7 @@ jest.mock('@/stores/app/server-url-store', () => ({ })); jest.mock('@/lib/env', () => ({ Env: { API_VERSION: 'v4' } })); -jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), error: jest.fn() } })); +jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() } })); jest.mock('@/api/config', () => ({ getSystemConfig: jest.fn().mockResolvedValue({ Data: { Locations: [] } }), })); diff --git a/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx b/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx new file mode 100644 index 00000000..d2e07e81 --- /dev/null +++ b/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx @@ -0,0 +1,242 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { ServerUrlBottomSheet } from '../server-url-bottom-sheet'; + +const mockGetSystemConfig = jest.fn(); +const mockGetUrl = jest.fn(); +const mockSetUrl = jest.fn(); +let mockOnValueChange: ((value: string) => void) | undefined; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +jest.mock('nativewind', () => ({ + styled: jest.fn((Component: any) => Component), + useColorScheme: () => ({ colorScheme: 'light' }), +})); +jest.mock('@/api/config', () => ({ + getSystemConfig: () => mockGetSystemConfig(), +})); +jest.mock('@/stores/app/server-url-store', () => ({ + useServerUrlStore: () => ({ getUrl: mockGetUrl, setUrl: mockSetUrl }), +})); +jest.mock('@/lib/env', () => ({ Env: { API_VERSION: 'v4' } })); +jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() } })); +jest.mock('lucide-react-native', () => ({ ChevronDownIcon: 'ChevronDownIcon' })); + +jest.mock('../../ui/actionsheet', () => { + const React = require('react'); + const { View } = require('react-native'); + const Passthrough = ({ children }: any) => React.createElement(View, {}, children); + return { + Actionsheet: ({ children, isOpen }: any) => (isOpen ? React.createElement(View, { testID: 'actionsheet' }, children) : null), + ActionsheetBackdrop: Passthrough, + ActionsheetContent: Passthrough, + ActionsheetDragIndicator: Passthrough, + ActionsheetDragIndicatorWrapper: Passthrough, + }; +}); +jest.mock('../../ui/button', () => { + const React = require('react'); + const { Text, TouchableOpacity, View } = require('react-native'); + return { + Button: ({ children, onPress, disabled }: any) => React.createElement(TouchableOpacity, { onPress, disabled }, children), + ButtonText: ({ children }: any) => React.createElement(Text, {}, children), + ButtonSpinner: () => React.createElement(View, { testID: 'button-spinner' }), + }; +}); +jest.mock('../../ui/form-control', () => { + const React = require('react'); + const { Text, View } = require('react-native'); + const Passthrough = ({ children }: any) => React.createElement(View, {}, children); + return { + FormControl: Passthrough, + FormControlLabel: Passthrough, + FormControlLabelText: ({ children }: any) => React.createElement(Text, {}, children), + FormControlHelperText: Passthrough, + FormControlError: Passthrough, + FormControlErrorText: ({ children }: any) => React.createElement(Text, {}, children), + }; +}); +jest.mock('../../ui/select', () => { + const React = require('react'); + const { Text, View } = require('react-native'); + const Passthrough = ({ children }: any) => React.createElement(View, {}, children); + return { + Select: ({ children, onValueChange }: any) => { + mockOnValueChange = onValueChange; + return React.createElement(View, {}, children); + }, + SelectBackdrop: Passthrough, + SelectContent: Passthrough, + SelectDragIndicator: Passthrough, + SelectDragIndicatorWrapper: Passthrough, + SelectIcon: Passthrough, + SelectInput: ({ value }: any) => React.createElement(Text, { testID: 'select-input' }, value), + SelectItem: ({ label, value }: any) => React.createElement(Text, { testID: `select-item-${value}` }, label), + SelectPortal: Passthrough, + SelectTrigger: Passthrough, + }; +}); +jest.mock('../../ui/input', () => { + const React = require('react'); + const { TextInput, View } = require('react-native'); + return { + Input: ({ children }: any) => React.createElement(View, {}, children), + InputField: (props: any) => React.createElement(TextInput, { testID: 'server-url-input', ...props }), + }; +}); +jest.mock('../../ui/center', () => { + const React = require('react'); + const { View } = require('react-native'); + return { Center: ({ children }: any) => React.createElement(View, {}, children) }; +}); +jest.mock('../../ui/hstack', () => { + const React = require('react'); + const { View } = require('react-native'); + return { HStack: ({ children }: any) => React.createElement(View, {}, children) }; +}); +jest.mock('../../ui/vstack', () => { + const React = require('react'); + const { View } = require('react-native'); + return { VStack: ({ children }: any) => React.createElement(View, {}, children) }; +}); +jest.mock('../../ui/text', () => { + const React = require('react'); + const { Text } = require('react-native'); + return { Text: ({ children }: any) => React.createElement(Text, {}, children) }; +}); +jest.mock('../../ui/spinner', () => { + const React = require('react'); + const { View } = require('react-native'); + return { Spinner: () => React.createElement(View, { testID: 'spinner' }) }; +}); + +const hostedConfig = { + Data: { + Locations: [ + { Name: 'US-West', DisplayName: 'Resgrid North America (Global)', LocationInfo: '', IsDefault: true, ApiUrl: 'https://api.resgrid.com', AllowsFreeAccounts: true }, + { Name: 'EU-Central', DisplayName: 'Resgrid Europe', LocationInfo: '', IsDefault: false, ApiUrl: 'https://api-eu-central.resgrid.com', AllowsFreeAccounts: false }, + ], + }, +}; + +describe('ServerUrlBottomSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockOnValueChange = undefined; + mockGetSystemConfig.mockResolvedValue(hostedConfig); + mockGetUrl.mockResolvedValue('https://api.resgrid.com/api/v4'); + mockSetUrl.mockResolvedValue(undefined); + }); + + const renderSheet = (props: Partial> = {}) => render(); + + it('lists the US-West and EU-Central sites plus a Custom option', async () => { + renderSheet(); + + expect(await screen.findByTestId('select-item-US-West')).toHaveTextContent('US-West'); + expect(screen.getByTestId('select-item-EU-Central')).toHaveTextContent('EU-Central'); + expect(screen.getByTestId('select-item-__custom__')).toHaveTextContent('settings.custom'); + }); + + it('still lists the hosted sites when the configured server is unreachable', async () => { + mockGetSystemConfig.mockRejectedValue(new Error('Network Error')); + mockGetUrl.mockResolvedValue('https://offline.example.org/api/v4'); + + renderSheet(); + + expect(await screen.findByTestId('select-item-US-West')).toBeTruthy(); + expect(screen.getByTestId('select-item-EU-Central')).toBeTruthy(); + expect(screen.getByTestId('select-input')).toHaveTextContent('settings.custom'); + expect(screen.getByTestId('server-url-input').props.value).toBe('https://offline.example.org'); + }); + + it('preselects the hosted site matching the saved url', async () => { + mockGetUrl.mockResolvedValue('https://api-eu-central.resgrid.com/api/v4'); + + renderSheet(); + + await waitFor(() => expect(screen.getByTestId('select-input')).toHaveTextContent('EU-Central')); + expect(screen.getByTestId('server-url-input').props.value).toBe('https://api-eu-central.resgrid.com'); + expect(screen.getByTestId('server-url-input').props.editable).toBe(false); + }); + + it('saves the selected hosted site and reports the server change', async () => { + const onClose = jest.fn(); + const onUrlChanged = jest.fn().mockResolvedValue(undefined); + renderSheet({ onClose, onUrlChanged }); + await screen.findByTestId('select-item-EU-Central'); + + act(() => mockOnValueChange?.('EU-Central')); + fireEvent.press(screen.getByText('common.save')); + + await waitFor(() => expect(mockSetUrl).toHaveBeenCalledWith('https://api-eu-central.resgrid.com/api/v4')); + await waitFor(() => expect(onUrlChanged).toHaveBeenCalledTimes(1)); + expect(onClose).toHaveBeenCalled(); + }); + + it('restores the previous url when signing out of the old server fails, so a retry signs out again', async () => { + const onClose = jest.fn(); + const onUrlChanged = jest.fn().mockRejectedValueOnce(new Error('sign out failed')).mockResolvedValue(undefined); + let savedUrl = 'https://api.resgrid.com/api/v4'; + mockGetUrl.mockImplementation(async () => savedUrl); + mockSetUrl.mockImplementation(async (url: string) => { + savedUrl = url; + }); + renderSheet({ onClose, onUrlChanged }); + await screen.findByTestId('select-item-EU-Central'); + + act(() => mockOnValueChange?.('EU-Central')); + fireEvent.press(screen.getByText('common.save')); + + await waitFor(() => expect(mockSetUrl).toHaveBeenLastCalledWith('https://api.resgrid.com/api/v4')); + expect(mockSetUrl).toHaveBeenCalledWith('https://api-eu-central.resgrid.com/api/v4'); + expect(await screen.findByText('sign out failed')).toBeTruthy(); + expect(onClose).not.toHaveBeenCalled(); + + fireEvent.press(screen.getByText('common.save')); + + await waitFor(() => expect(onUrlChanged).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); + + it('does not report a change when the same server is saved again', async () => { + const onClose = jest.fn(); + const onUrlChanged = jest.fn(); + renderSheet({ onClose, onUrlChanged }); + await waitFor(() => expect(screen.getByTestId('select-input')).toHaveTextContent('US-West')); + + fireEvent.press(screen.getByText('common.save')); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(mockSetUrl).toHaveBeenCalledWith('https://api.resgrid.com/api/v4'); + expect(onUrlChanged).not.toHaveBeenCalled(); + }); + + it('saves a custom url with the api suffix', async () => { + const onClose = jest.fn(); + renderSheet({ onClose }); + await screen.findByTestId('select-item-__custom__'); + + act(() => mockOnValueChange?.('__custom__')); + fireEvent.changeText(await screen.findByTestId('server-url-input'), 'https://resgrid.example.org/'); + fireEvent.press(screen.getByText('common.save')); + + await waitFor(() => expect(mockSetUrl).toHaveBeenCalledWith('https://resgrid.example.org/api/v4')); + expect(onClose).toHaveBeenCalled(); + }); + + it('rejects a non-https custom url', async () => { + renderSheet(); + await screen.findByTestId('select-item-__custom__'); + + act(() => mockOnValueChange?.('__custom__')); + fireEvent.changeText(await screen.findByTestId('server-url-input'), 'http://resgrid.example.org'); + fireEvent.press(screen.getByText('common.save')); + + expect(await screen.findByText('form.invalid_url')).toBeTruthy(); + expect(mockSetUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/settings/server-url-bottom-sheet.tsx b/src/components/settings/server-url-bottom-sheet.tsx index 20147d94..fb27fdf0 100644 --- a/src/components/settings/server-url-bottom-sheet.tsx +++ b/src/components/settings/server-url-bottom-sheet.tsx @@ -5,9 +5,8 @@ import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { Platform, ScrollView } from 'react-native'; -import { getSystemConfig } from '@/api/config'; import { logger } from '@/lib/logging'; -import { buildApiUrl, CUSTOM_SERVER_VALUE, toBaseUrl, URL_PATTERN } from '@/lib/server-url'; +import { buildApiUrl, CUSTOM_SERVER_VALUE, findLocationByUrl, isSameServerUrl, loadServerLocations, toBaseUrl, URL_PATTERN } from '@/lib/server-url'; import { type ResgridSystemLocation } from '@/models/v4/configs/getSystemConfigResultData'; import { useServerUrlStore } from '@/stores/app/server-url-store'; @@ -21,6 +20,7 @@ import { Select, SelectBackdrop, SelectContent, SelectDragIndicator, SelectDragI import { Spinner } from '../ui/spinner'; import { Text } from '../ui/text'; import { VStack } from '../ui/vstack'; + interface ServerUrlForm { url: string; } @@ -28,9 +28,12 @@ interface ServerUrlForm { interface ServerUrlBottomSheetProps { isOpen: boolean; onClose: () => void; + // Called after a save that switched to a different server. The session's tokens belong to + // the previous server, so a signed-in caller should log out (as Responder does). + onUrlChanged?: () => Promise | void; } -export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetProps) { +export function ServerUrlBottomSheet({ isOpen, onClose, onUrlChanged }: ServerUrlBottomSheetProps) { const { t } = useTranslation(); const { colorScheme } = useColorScheme(); const [isLoading, setIsLoading] = React.useState(false); @@ -43,6 +46,8 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr control, handleSubmit, setValue, + setError, + clearErrors, formState: { errors }, } = useForm(); @@ -57,25 +62,15 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr const loadServers = async () => { setIsLoadingServers(true); + clearErrors(); try { // The persisted value includes the /api/vX suffix; reduce it to a bare base so we // can match it against a hosted site or show it for editing in the custom field. const currentUrl = await getUrl(); const currentBaseUrl = toBaseUrl(currentUrl); - - let fetchedLocations: ResgridSystemLocation[] = []; - try { - const result = await getSystemConfig(); - fetchedLocations = result?.Data?.Locations ?? []; - } catch (error) { - // The list of hosted sites is best-effort; on failure the user can still enter a - // custom URL manually, so we degrade gracefully instead of blocking the sheet. - logger.error({ - message: 'Failed to load Resgrid hosted sites', - context: { error }, - }); - } + // Always includes the Resgrid hosted sites, even if the current server can't be reached. + const fetchedLocations = await loadServerLocations(); if (isCancelled) { return; @@ -85,7 +80,7 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr // Preselect the hosted site whose API URL matches the persisted URL; otherwise fall // back to the Custom option and show the persisted URL so the user can edit it. - const matchedLocation = fetchedLocations.find((location) => toBaseUrl(location.ApiUrl) === currentBaseUrl); + const matchedLocation = findLocationByUrl(fetchedLocations, currentUrl); if (matchedLocation) { setSelectedServer(matchedLocation.Name); setValue('url', toBaseUrl(matchedLocation.ApiUrl)); @@ -105,7 +100,7 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr return () => { isCancelled = true; }; - }, [isOpen, getUrl, setValue]); + }, [isOpen, getUrl, setValue, clearErrors]); const handleServerChange = React.useCallback( (nextServer: string) => { @@ -130,24 +125,38 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr // the full API URL (with the /api/vX suffix) that the API client expects. const resolvedBaseUrl = isCustomSelected ? data.url : (location?.ApiUrl ?? data.url); const apiUrl = buildApiUrl(resolvedBaseUrl); + const previousUrl = await getUrl(); await setUrl(apiUrl); logger.info({ message: 'Server URL updated successfully', context: { url: apiUrl, server: selectedServer }, }); + + if (onUrlChanged && !isSameServerUrl(previousUrl, apiUrl)) { + try { + await onUrlChanged(); + } catch (error) { + // The session still belongs to the previous server. Put its URL back so a retry sees the change + // and signs out again, instead of finding the new URL already saved and closing. + await setUrl(previousUrl); + throw error; + } + } + onClose(); } catch (error) { logger.error({ message: 'Failed to update server URL', context: { error }, }); + setError('root', { message: error instanceof Error ? error.message : t('common.error') }); } finally { setIsLoading(false); } }; const selectedLocation = locations.find((location) => location.Name === selectedServer); - const selectedServerLabel = isCustomSelected ? t('settings.custom') : selectedLocation?.DisplayName || selectedLocation?.Name; + const selectedServerLabel = isCustomSelected ? t('settings.custom') : selectedLocation?.Name; return ( @@ -183,7 +192,7 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr {locations.map((location) => ( - + ))} @@ -242,6 +251,12 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr
+ {errors.root?.message ? ( + + {errors.root.message} + + ) : null} +